diff --git a/.github/ISSUE_TEMPLATE/safari-beta-bug.yml b/.github/ISSUE_TEMPLATE/safari-beta-bug.yml new file mode 100644 index 0000000000..fc8313ab51 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/safari-beta-bug.yml @@ -0,0 +1,107 @@ +name: Safari beta bug report +description: Report a bug from the macOS Safari TestFlight beta or local Safari beta validation flow. +title: "[Safari beta] " +body: + - type: markdown + attributes: + value: | + Please include the diagnostics report from `debug.html` whenever possible. Copy or download it from the extension first, then paste it below. + + - type: input + id: macos_version + attributes: + label: macOS version + placeholder: "e.g. macOS 26.2" + validations: + required: true + + - type: input + id: safari_version + attributes: + label: Safari version + placeholder: "e.g. Safari 18.4" + validations: + required: true + + - type: input + id: res_version + attributes: + label: RES version or build + placeholder: "e.g. 5.24.8 beta / TestFlight build 3" + validations: + required: true + + - type: dropdown + id: install_source + attributes: + label: Install source + options: + - TestFlight + - Local Xcode build + - Other + validations: + required: true + + - type: dropdown + id: reddit_surface + attributes: + label: Where did the issue happen? + options: + - Old Reddit + - New Reddit + - Both + - Not tied to a Reddit page + validations: + required: true + + - type: textarea + id: page_url + attributes: + label: Affected page or extension view + description: Paste the page URL or say `options.html`, `debug.html`, or another extension page. + placeholder: "https://old.reddit.com/r/test" + validations: + required: true + + - type: textarea + id: repro_steps + attributes: + label: Reproduction steps + description: List the exact steps needed to reproduce the issue. + placeholder: | + 1. Open ... + 2. Click ... + 3. Observe ... + validations: + required: true + + - type: textarea + id: expected_behavior + attributes: + label: Expected behavior + placeholder: "What should have happened?" + validations: + required: true + + - type: textarea + id: actual_behavior + attributes: + label: Actual behavior + placeholder: "What happened instead?" + validations: + required: true + + - type: textarea + id: diagnostics_report + attributes: + label: Diagnostics report from debug.html + description: Paste the copied report, or explain why you could not capture one. + render: text + validations: + required: true + + - type: textarea + id: additional_context + attributes: + label: Additional context + description: Screenshots, Xcode console output, permission prompts, or other extensions that may be relevant. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 83bc042fa2..cbd173d890 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,9 @@ Relevant issue: Tested in browser: + +If this PR touches `lib/environment/`, browser manifests, or build target logic, complete this before marking it review-ready: + +- [ ] Chrome smoke: injects on old Reddit, settings persist, toolbar style toggle works, `showImages` works, downloads remain managed, history-dependent behavior still works +- [ ] Firefox smoke: injects on old Reddit, settings persist, toolbar style toggle works, `showImages` works, downloads remain managed, history-dependent behavior still works, Firefox-specific auth/storage behavior still matches expectations +- [ ] Safari smoke (if applicable): use the Safari checklist in `CONTRIBUTING.md` diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index ba8a6cb54e..8cb8124c39 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -43,10 +43,34 @@ jobs: dist/zip/edge.zip dist/zip/firefox.zip dist/zip/opera.zip + dist/zip/safari.zip prerelease: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + safari_validation: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: yarn install --frozen-lockfile + + - run: yarn test + - run: yarn eslint + - run: yarn safari:validate + - run: tar -C dist -czf dist/zip/safari-xcode.tar.gz safari-xcode + + - uses: actions/upload-artifact@v4 + with: + name: safari-validation-artifacts + path: | + dist/zip/safari.zip + dist/zip/safari-xcode.tar.gz + if-no-files-found: error + retention-days: 7 + windows: runs-on: windows-latest steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 07806aa533..27b462b495 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,10 @@ To load the extension into your browser, see [Loading RES into your browser](#lo **`yarn build [--browsers ]`** will clean `dist/`, then build RES (release mode). Each build output will be compressed to a .zip file in `dist/zip/`. -`` is a comma-separated list of browsers to target, e.g. `chrome,firefox`. `all` will build all targets. By default, `chrome` will be targeted. +`` is a comma-separated list of browsers to target, e.g. `chrome,firefox,safari`. `all` will build all targets. By default, `chrome` will be targeted. + +Safari builds emit a WebExtension bundle in `dist/safari/` and `dist/zip/safari.zip`. Packaging that bundle for distribution still requires Xcode on macOS. +For the broad public beta/TestFlight rollout, use [`docs/safari-public-beta.md`](docs/safari-public-beta.md) as the source of truth. #### Lint and test commands @@ -69,11 +72,53 @@ The default host and port (`localhost` and `4444`) should work for most local in 1. Click `Load Temporary Add-on` and select `/dist/firefox/manifest.json` (not the `/firefox` folder). 1. Any time you make changes, you must go back to the `about:debugging` page and `Reload` the extension. +##### Safari (macOS) + +1. Install Xcode and its command line tools on macOS. +1. If the converter fails with `A required plugin failed to load`, run `xcodebuild -runFirstLaunch` once. +1. Run `yarn safari:validate` to build `dist/safari`, convert it into `dist/safari-xcode/`, patch the generated Xcode bundle identifiers, and verify that the app target can be built locally without signing. +1. If you need to sign the app locally with your own Apple team, rebuild with a unique base bundle ID first, for example `RES_SAFARI_BUNDLE_IDENTIFIER=com..redditenhancementsuitesafari yarn safari:validate`. The extension bundle ID is derived automatically as `.extension`. +1. Open `/dist/safari-xcode/Reddit Enhancement Suite Safari/Reddit Enhancement Suite Safari.xcodeproj` in Xcode, run the containing app locally to enable the extension in Safari, and use Xcode for reload/debug cycles. +1. Public distribution goes through Apple's Safari Web Extension packaging flow and App Store Connect rather than the browser store automation used for Chrome and Firefox. +1. Broad public Safari testing should use TestFlight rather than GitHub artifacts; see [`docs/safari-public-beta.md`](docs/safari-public-beta.md). + +Safari smoke checklist after the containing app is installed: + + - Extension enables and injects on old Reddit. + - Options page opens and settings/translations load. + - Toolbar action still toggles subreddit CSS. + - `showImages` expands media and optional host-permission prompts still work. + - Download actions open the asset for manual save. + - History-dependent UI is absent or intentionally inert, without console errors. + - File backup/restore works. + - Cloud backup works for manual auth only, or the provider is intentionally hidden if manual auth fails. + - Private browsing does not crash the extension. + - If the options page is blank or the toolbar click does nothing, open `debug.html` inside the extension bundle to inspect persisted Safari runtime diagnostics and clear them between attempts. + - If you need to file a Safari bug, use the copy/download actions on `debug.html` and submit the report through the GitHub Safari beta issue template. + +Known Safari gaps in the current port: + + - No extension-managed browsing history; history-dependent filters and showImages history writes are disabled. + - No controlled extension downloads; Safari falls back to opening the asset in a new tab for manual save. + - Automatic cloud backups remain disabled until Safari background redirect auth is validated manually. + - Safari runtime diagnostics are enabled by default and stored in extension local storage until the port stabilizes. + +#### Cross-browser regression smoke + +If a change touches `lib/environment/`, browser manifests, or build target logic, do not mark the PR review-ready until this short smoke pass has been completed on the PR branch: + + - Chrome on old Reddit: RES injects, settings save/reload, toolbar style toggle works on a styled subreddit, `showImages` expands media, downloads remain managed, and history-based behavior still works. + - Firefox on old Reddit: same smoke pass, plus Firefox-specific auth/storage behavior still matches expected pre-Safari behavior. + - Safari: keep the existing Safari smoke checklist above. + +Chrome is the representative Chromium runtime for these checks. Edge and Opera only need build validation unless Chrome shows a regression. + ## Project structure #### Top level files and folders - `.github/`: Github templates + - `docs/`: release and rollout documentation - `browser/`: extension API files common to all browsers - `build/`: files handling automated browser deployments - `changelog/`: release changelogs @@ -93,6 +138,7 @@ The default host and port (`localhost` and `4444`) should work for most local in - `lib/vendor/`: RES vendor libraries (old libs not on npm) - `lib/**/__tests__`: unit tests - `locales`: RES i18n translations + - `safari/`: Safari-specific RES files - `tests/`: integration tests - `package.json`: package info, dependencies - `build.js`: build script diff --git a/build.js b/build.js index 1fbd18869b..b86e5bcb14 100644 --- a/build.js +++ b/build.js @@ -35,6 +35,12 @@ const targets = { manifest: './chrome/manifest.json', noSourcemap: true, }, + safari: { + browserName: 'safari', + browserMinVersion: '18.4', + manifest: './safari/manifest.json', + noSourcemap: true, + }, firefox: { browserName: 'firefox', browserMinVersion: '115.0', @@ -70,22 +76,26 @@ const updatedURL /*: string */ = isBeta ? `https://redditenhancementsuite.com/releases/beta/#v${version}` : `https://redditenhancementsuite.com/releases/#v${version}`; const homepageURL /*: string */ = packageInfo.homepage; +const repositoryURL /*: string */ = `https://github.com/${packageInfo.repository.username}/${packageInfo.repository.repository}`; +const safariBetaIssueURL /*: string */ = `${repositoryURL}/issues/new?template=safari-beta-bug.yml`; // used for invalidating caches on each build (executed at build time) // production builds uses version number to keep the build reproducible const buildToken = isProduction ? version : devBuildToken; -async function buildForBrowser(targetName, { manifest, noSourceMap, browserName, browserMinVersion, browserMobileMinVersion }) { +async function buildForBrowser(targetName, { manifest, noSourcemap, browserName, browserMinVersion, browserMobileMinVersion }) { const context = { entryPoints: { 'foreground.entry': './lib/foreground.entry.js', 'background.entry': './lib/background.entry.js', + 'debug.entry': './lib/debug/debug.entry.js', + 'diagnosticsLink.entry': './lib/options/diagnosticsLink.entry.js', 'options.entry': './lib/options/options.entry.js', 'prompt.entry': './lib/environment/background/permissions/prompt.entry.js', manifest, options: './lib/options/options.scss', res: './lib/css/res.scss', }, - sourcemap: !isProduction || !noSourceMap, + sourcemap: !isProduction || !noSourcemap, outdir: `./dist/${targetName}/`, bundle: true, format: 'iife', @@ -113,6 +123,8 @@ async function buildForBrowser(targetName, { manifest, noSourceMap, browserName, 'process.env.isMajor': `"${isMajor.toString()}"`, 'process.env.updatedURL': `"${updatedURL}"`, 'process.env.homepageURL': `"${homepageURL}"`, + 'process.env.repositoryURL': `"${repositoryURL}"`, + 'process.env.safariBetaIssueURL': `"${safariBetaIssueURL}"`, }, plugins: [ { @@ -138,6 +150,7 @@ async function buildForBrowser(targetName, { manifest, noSourceMap, browserName, { from: ['./images/css-on.png'], to: ['./'] }, { from: ['./images/icon128.png'], to: ['./'] }, { from: ['./images/icon48.png'], to: ['./'] }, + { from: ['./lib/debug/debug.html'], to: ['./'] }, { from: ['./lib/environment/background/permissions/prompt.html'], to: ['./'] }, { from: ['./lib/options/options.html'], to: ['./'] }, { from: ['./node_modules/dashjs/dist/dash.mediaplayer.min.js'], to: ['./'] }, diff --git a/build/safariConvert.js b/build/safariConvert.js new file mode 100644 index 0000000000..c578699852 --- /dev/null +++ b/build/safariConvert.js @@ -0,0 +1,127 @@ +/* @noflow */ +/* eslint import/no-nodejs-modules: 0 */ + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { spawnSync } from 'node:child_process'; + +const defaultAppName = 'Reddit Enhancement Suite Safari'; +const defaultBundleIdentifier = 'com.honestbleeps.redditenhancementsuitesafari'; + +function getExtensionBundleIdentifier(bundleIdentifier) { + return `${bundleIdentifier}.extension`; +} + +function parseArgs(argv) { + const flags = new Map(); + const entries = argv.entries(); + + for (const [index, arg] of entries) { + if (!arg.startsWith('--')) continue; + + const [rawKey, inlineValue] = arg.split('='); + const key = rawKey.slice(2); + if (inlineValue !== undefined) { + flags.set(key, inlineValue); + continue; + } + + const next = argv[index + 1]; + if (next && !next.startsWith('--')) { + flags.set(key, next); + entries.next(); + } else { + flags.set(key, true); + } + } + + return flags; +} + +function run(command, args) { + const result = spawnSync(command, args, { + stdio: 'inherit', + }); + + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status || 1); +} + +function patchProjectBundleIdentifiers(projectFile, bundleIdentifier) { + if (!fs.existsSync(projectFile)) { + throw new Error(`Safari Xcode project not found: ${projectFile}`); + } + + const bundlePattern = /PRODUCT_BUNDLE_IDENTIFIER = (?:"([^"]+)"|([A-Za-z0-9._-]+));/g; + const extensionBundleIdentifier = getExtensionBundleIdentifier(bundleIdentifier); + const original = fs.readFileSync(projectFile, 'utf8'); + const updated = original.replace(bundlePattern, (match, quotedValue, bareValue) => { + const currentValue = quotedValue || bareValue; + const nextValue = /\.(Extension|extension)$/.test(currentValue) ? extensionBundleIdentifier : bundleIdentifier; + const encoded = quotedValue ? `"${nextValue}"` : nextValue; + return `PRODUCT_BUNDLE_IDENTIFIER = ${encoded};`; + }); + + if (original === updated) { + throw new Error(`No PRODUCT_BUNDLE_IDENTIFIER entries were patched in ${projectFile}`); + } + + fs.writeFileSync(projectFile, updated); +} + +const args = parseArgs(process.argv.slice(2)); +const appName = args.get('app-name') || defaultAppName; +const bundleIdentifier = + args.get('bundle-identifier') || + process.env.RES_SAFARI_BUNDLE_IDENTIFIER || + defaultBundleIdentifier; +const source = path.resolve(args.get('source') || 'dist/safari'); +const projectLocation = path.resolve(args.get('project-location') || 'dist/safari-xcode'); +const validateBuild = args.has('validate-build'); + +if (!fs.existsSync(source)) { + throw new Error(`Safari bundle not found at ${source}. Run "yarn build --browsers safari" first.`); +} + +run('xcrun', [ + 'safari-web-extension-converter', + source, + '--project-location', projectLocation, + '--app-name', appName, + '--bundle-identifier', bundleIdentifier, + '--macos-only', + '--swift', + '--copy-resources', + '--no-open', + '--no-prompt', + '--force', +]); + +const projectDirectory = path.join(projectLocation, appName); +const projectPath = path.join(projectDirectory, `${appName}.xcodeproj`); +const projectFile = path.join(projectPath, 'project.pbxproj'); + +patchProjectBundleIdentifiers(projectFile, bundleIdentifier); + +console.log(`Patched Xcode bundle identifiers in ${projectFile}`); +console.log(`App bundle identifier: ${bundleIdentifier}`); +console.log(`Extension bundle identifier: ${getExtensionBundleIdentifier(bundleIdentifier)}`); + +if (validateBuild) { + run('xcodebuild', [ + '-list', + '-project', projectPath, + ]); + + run('xcodebuild', [ + '-project', projectPath, + '-scheme', appName, + '-configuration', 'Debug', + '-destination', 'platform=macOS', + 'build', + 'CODE_SIGNING_ALLOWED=NO', + ]); +} + +console.log(`Open ${projectPath} in Xcode and run the containing app to enable the extension in Safari.`); diff --git a/changelog/UNRELEASED.md b/changelog/UNRELEASED.md index 8124fa4efe..2ac786ba44 100644 --- a/changelog/UNRELEASED.md +++ b/changelog/UNRELEASED.md @@ -4,8 +4,11 @@ ### Bug Fixes -- None (thanks nobody) +- Added a Safari build target, local Xcode conversion/validation workflow, and explicit Safari fallbacks for unsupported history/download APIs. +- Added Safari runtime diagnostics, a `debug.html` troubleshooting page, and non-fatal Safari toolbar/options fallbacks so runtime failures no longer fail silently. +- Added Safari beta diagnostics export/copy actions, GitHub Safari beta issue intake, and TestFlight rollout guidance for broader public testing. ### Housekeeping / Other -- None (thanks nobody) +- Documented the Safari smoke-test checklist and the current Safari-only support gaps for local validation and future releases. +- Added a macOS Safari validation lane in GitHub Actions so maintainers can verify the Xcode conversion path and collect Safari artifacts from CI. diff --git a/docs/safari-public-beta.md b/docs/safari-public-beta.md new file mode 100644 index 0000000000..f49a18a15c --- /dev/null +++ b/docs/safari-public-beta.md @@ -0,0 +1,84 @@ +# Safari Public Beta Rollout + +This document covers the first broad Safari beta for RES. The public install path is **TestFlight only**. GitHub artifacts are for maintainers and code review, not for public installation. + +## Preconditions + +- Apple Developer Program enrollment is complete. +- App Store Connect access exists for the RES Safari container app. +- The Safari bundle identifiers remain: + - `com.honestbleeps.redditenhancementsuitesafari` + - `com.honestbleeps.redditenhancementsuitesafari.Extension` +- The branch has a draft PR open for code review and release context before the public beta link is shared. + +## Release Checklist + +1. Run local validation: + - `yarn test` + - `yarn eslint` + - `yarn safari:validate` +2. Confirm the macOS GitHub Actions Safari validation job is green on the beta branch or draft PR. +3. Open `/dist/safari-xcode/Reddit Enhancement Suite Safari/Reddit Enhancement Suite Safari.xcodeproj` in Xcode. +4. Set the Apple team/signing configuration for the app target and extension target. +5. Archive the app from Xcode and upload it with Organizer to App Store Connect. +6. In App Store Connect, complete any required app metadata, privacy, and export-compliance fields before enabling testing. +7. Create an internal TestFlight group first and run the Safari smoke checklist: + - extension enables in Safari + - old Reddit injects correctly + - settings open and persist + - subreddit style toggle works on a styled subreddit + - `showImages` expands media and the download fallback opens a new tab + - file backup/restore works + - private browsing does not crash + - `debug.html` stays clean or captures actionable errors +8. Only after the internal pass is stable, create the external/public TestFlight group or public link. +9. Create a GitHub tracking issue or point testers to the Safari beta issue template before the external link is posted. +10. Publish the `/r/Enhancement` announcement only after the public TestFlight link works. + +## TestFlight Metadata + +### Beta App Description + +```text +Reddit Enhancement Suite for Safari is in public beta on macOS. This build focuses on old Reddit support and best-effort parity with the existing Chrome and Firefox releases while Safari-specific gaps are still being validated. +``` + +### What to Test + +```text +Please focus on old.reddit.com injection, settings persistence, subreddit style toggling, showImages media expansion, file backup/restore, and private browsing stability. If you hit an issue, open the RES Safari diagnostics page, copy or download the report, and include it in a GitHub Safari beta bug report. +``` + +### Known Limitations + +```text +Safari currently does not support extension-managed browsing history, so history-dependent RES behavior is disabled. Downloads opened from showImages fall back to opening the asset in a new tab for manual save. Cloud backup may be limited to manual auth or file-based backup/restore only while Safari auth behavior is still being validated. +``` + +### Feedback Contact + +App Store Connect requires a feedback email for TestFlight. Use the appropriate maintainer/team mailbox there, but direct actionable bug reports to the GitHub Safari beta issue template so reports stay searchable and triageable. + +## Tester Feedback Path + +- Primary bug intake: GitHub Safari beta issue template +- Community discussion and announcements: `/r/Enhancement` +- Required bug report data: + - macOS version + - Safari version + - RES version/build + - whether the issue is on old Reddit, new Reddit, or both + - reproduction steps + - actual vs expected behavior + - diagnostics report from `debug.html` + +## Reddit Announcement Checklist + +The `/r/Enhancement` post should include: + +- macOS and Safari version target +- install path: TestFlight only +- current Safari limitations +- link to the draft PR or beta tracking context for changelog/review visibility +- link to the GitHub Safari beta issue template for bug reports +- request to include the exported diagnostics report when filing issues diff --git a/lib/background.entry.js b/lib/background.entry.js index 3a6f257b60..23d0aafa60 100644 --- a/lib/background.entry.js +++ b/lib/background.entry.js @@ -1,5 +1,7 @@ /* @flow */ +import './environment/background/diagnosticsBootstrap'; + // load environment listeners import './environment/background/ajax'; import './environment/background/auth'; @@ -18,9 +20,33 @@ import './environment/background/tabs'; import './environment/background/xhrCache'; import { migrate } from './core/migrate'; +import { reportDiagnostic } from './environment/utils/diagnostics'; -migrate(); +try { + reportDiagnostic({ + level: 'info', + message: 'Running background migrations.', + source: 'background', + stage: 'startup', + }); + migrate(); + reportDiagnostic({ + level: 'info', + message: 'Background entry initialized.', + source: 'background', + stage: 'startup', + }); +} catch (error) { + reportDiagnostic({ + level: 'error', + message: error.message || 'Background startup failed.', + source: 'background', + stack: error.stack || '', + stage: 'startup', + }); + throw error; +} // Restoring a backup may require migrations to be run addListener('runMigrations', migrate); diff --git a/lib/core/metadata/index.js b/lib/core/metadata/index.js index efb518863f..b668c77225 100644 --- a/lib/core/metadata/index.js +++ b/lib/core/metadata/index.js @@ -11,6 +11,8 @@ const isMinor = Boolean(downcast(process.env.isMinor, 'string')); const isMajor = Boolean(downcast(process.env.isMajor, 'string')); const updatedURL = downcast(process.env.updatedURL, 'string'); const homepageURL = downcast(process.env.homepageURL, 'string'); +const repositoryURL = downcast(process.env.repositoryURL, 'string'); +const safariBetaIssueURL = downcast(process.env.safariBetaIssueURL, 'string'); export { announcementsSubreddit, @@ -22,4 +24,6 @@ export { isMajor, updatedURL, homepageURL, + repositoryURL, + safariBetaIssueURL, }; diff --git a/lib/core/options/storage.js b/lib/core/options/storage.js index e70776fbd8..c7537f175c 100644 --- a/lib/core/options/storage.js +++ b/lib/core/options/storage.js @@ -1,11 +1,12 @@ /* @flow */ import * as Storage from '../../environment/foreground/storage'; +import { shouldBatchOptionStorage } from '../../environment/utils/capabilities'; import { getModuleId } from '../module'; import type { OpaqueModuleId } from '../module'; // Don't batch in Firefox, as that may make the storage very slow on init -export const storage = Storage.wrapPrefix('RESoptions.', (): { [string]: any } => ({}), undefined, process.env.BUILD_TARGET !== 'firefox'); +export const storage = Storage.wrapPrefix('RESoptions.', (): { [string]: any } => ({}), undefined, shouldBatchOptionStorage); export const loadRaw = (moduleId: string) => storage.get(moduleId); diff --git a/lib/debug/__tests__/report.js b/lib/debug/__tests__/report.js new file mode 100644 index 0000000000..39df97005d --- /dev/null +++ b/lib/debug/__tests__/report.js @@ -0,0 +1,50 @@ +/* @flow */ + +import test from 'ava'; + +import { formatDiagnosticReport } from '../report.js'; + +test('formatDiagnosticReport includes environment details and diagnostics', t => { + const report = formatDiagnosticReport({ + buildTarget: 'safari', + browser: 'Safari', + browserVersion: '18.4', + extensionId: 'extension-id', + generatedAt: Date.UTC(2026, 2, 14, 0, 0, 0), + issueURL: 'https://github.com/honestbleeps/Reddit-Enhancement-Suite/issues/new?template=safari-beta-bug.yml', + os: 'Mac', + pageURL: 'safari-web-extension://debug.html', + userAgent: 'Mozilla/5.0 Safari/605.1.15', + version: '5.24.8', + }, [{ + level: 'error', + message: 'Example failure', + source: 'options', + stack: 'Error: Example failure\n at bootstrap', + stage: 'startup', + timestamp: Date.UTC(2026, 2, 14, 0, 1, 0), + }]); + + t.true(report.includes('RES Safari Diagnostics Report')); + t.true(report.includes('- RES Version: 5.24.8')); + t.true(report.includes('- Build Target: safari')); + t.true(report.includes('- Browser: Safari 18.4')); + t.true(report.includes('- GitHub Safari Beta Issue Template: https://github.com/honestbleeps/Reddit-Enhancement-Suite/issues/new?template=safari-beta-bug.yml')); + t.true(report.includes('[error] 2026-03-14T00:01:00.000Z options/startup: Example failure')); + t.true(report.includes('Error: Example failure')); +}); + +test('formatDiagnosticReport handles an empty diagnostics buffer', t => { + const report = formatDiagnosticReport({ + buildTarget: 'safari', + browser: 'Safari', + browserVersion: '18.4', + extensionId: 'extension-id', + os: 'Mac', + pageURL: 'safari-web-extension://debug.html', + userAgent: 'Mozilla/5.0 Safari/605.1.15', + version: '5.24.8', + }, []); + + t.true(report.includes('- No diagnostics have been recorded.')); +}); diff --git a/lib/debug/debug.entry.js b/lib/debug/debug.entry.js new file mode 100644 index 0000000000..ad5435f9a2 --- /dev/null +++ b/lib/debug/debug.entry.js @@ -0,0 +1,233 @@ +/* global chrome */ +/* @flow */ + +import '../environment/foreground/diagnosticsBootstrap'; +import * as Metadata from '../core/metadata'; +import { buildTarget } from '../environment/utils/capabilities'; +import { clearDiagnostics, getDiagnostics } from '../environment/utils/diagnostics'; +import { getExtensionId, getURL } from '../environment'; +import { OS, browser, version as browserVersion } from '../utils/browserDetect'; +import { formatDiagnosticReport } from './report'; + +const body = document.body; +let latestEntries = []; +let lastStatusMessage = ''; + +function formatTimestamp(timestamp: number): string { + try { + return new Date(timestamp).toLocaleString(); + } catch (error) { + return String(timestamp); + } +} + +function getExtensionInfo() { + try { + return getExtensionId(); + } catch (error) { + return 'Unavailable'; + } +} + +function getReportContext() { + return { + buildTarget, + browser, + browserVersion: String(browserVersion), + extensionId: getExtensionInfo(), + issueURL: Metadata.safariBetaIssueURL, + os: OS, + pageURL: location.href, + userAgent: navigator.userAgent || 'Unavailable', + version: Metadata.version, + }; +} + +function createMetadataRow(label: string, value: string | HTMLElement) { + const item = document.createElement('li'); + const strong = document.createElement('strong'); + strong.textContent = `${label}: `; + item.append(strong); + + if (typeof value === 'string') { + item.append(value); + } else { + item.append(value); + } + + return item; +} + +function fallbackCopyText(text: string) { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', 'readonly'); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + textarea.style.pointerEvents = 'none'; + document.body.append(textarea); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + + const copied = typeof document.execCommand === 'function' && document.execCommand('copy'); + textarea.remove(); + + if (!copied) throw new Error('The browser rejected the fallback clipboard copy request.'); +} + +function setStatusMessage(message: string) { + lastStatusMessage = message; +} + +async function copyReport() { + const report = formatDiagnosticReport(getReportContext(), latestEntries); + + try { + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + await navigator.clipboard.writeText(report); + } else { + fallbackCopyText(report); + } + setStatusMessage('Copied the diagnostics report to the clipboard.'); + } catch (error) { + try { + fallbackCopyText(report); + setStatusMessage('Copied the diagnostics report to the clipboard.'); + } catch (fallbackError) { + setStatusMessage(`Could not copy the diagnostics report automatically: ${fallbackError.message}`); + } + } + + renderDiagnostics(latestEntries); +} + +function downloadReport() { + const report = formatDiagnosticReport(getReportContext(), latestEntries); + const blob = new Blob([report], { type: 'text/plain;charset=utf-8' }); + const href = URL.createObjectURL(blob); + const downloadLink = document.createElement('a'); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + + downloadLink.href = href; + downloadLink.download = `res-safari-diagnostics-${timestamp}.txt`; + downloadLink.click(); + + setStatusMessage('Downloaded a diagnostics report.'); + renderDiagnostics(latestEntries); + + setTimeout(() => URL.revokeObjectURL(href), 0); +} + +function renderDiagnostics(entries) { + latestEntries = entries; + body.replaceChildren(); + + const container = document.createElement('main'); + const title = document.createElement('h1'); + title.textContent = 'RES Safari Diagnostics'; + + const intro = document.createElement('p'); + intro.textContent = 'Use this page to inspect runtime failures captured from the Safari extension and export them for GitHub bug reports.'; + + const actions = document.createElement('p'); + const clearButton = document.createElement('button'); + clearButton.type = 'button'; + clearButton.textContent = 'Clear diagnostics'; + clearButton.addEventListener('click', async () => { + await clearDiagnostics(); + setStatusMessage('Cleared stored diagnostics.'); + renderDiagnostics([]); + }); + + const copyButton = document.createElement('button'); + copyButton.type = 'button'; + copyButton.textContent = 'Copy report'; + copyButton.addEventListener('click', () => { + copyReport().catch(error => { + setStatusMessage(`Could not copy the diagnostics report: ${error.message}`); + renderDiagnostics(latestEntries); + }); + }); + + const downloadButton = document.createElement('button'); + downloadButton.type = 'button'; + downloadButton.textContent = 'Download report'; + downloadButton.addEventListener('click', downloadReport); + + const optionsLink = document.createElement('a'); + optionsLink.href = getURL('options.html'); + optionsLink.textContent = 'Open RES settings'; + + const issueLink = document.createElement('a'); + issueLink.href = Metadata.safariBetaIssueURL; + issueLink.textContent = 'Open Safari beta bug report'; + issueLink.rel = 'noopener noreferrer'; + issueLink.target = '_blank'; + + actions.append( + clearButton, + document.createTextNode(' '), + copyButton, + document.createTextNode(' '), + downloadButton, + document.createTextNode(' '), + optionsLink, + document.createTextNode(' '), + issueLink, + ); + container.append(title, intro, actions); + + const status = document.createElement('p'); + status.textContent = lastStatusMessage || 'Copy or download the report before filing a Safari beta issue.'; + container.append(status); + + const metadata = document.createElement('ul'); + metadata.append( + createMetadataRow('RES version', Metadata.version), + createMetadataRow('Build target', buildTarget), + createMetadataRow('Extension ID', getExtensionInfo()), + createMetadataRow('Browser', `${browser} ${String(browserVersion)}`), + createMetadataRow('OS', OS), + createMetadataRow('Current page', location.href), + ); + container.append(metadata); + + if (!entries.length) { + const empty = document.createElement('p'); + empty.textContent = 'No diagnostics have been recorded yet.'; + container.append(empty); + body.append(container); + return; + } + + const list = document.createElement('ol'); + for (const entry of entries.slice().reverse()) { + const item = document.createElement('li'); + const heading = document.createElement('p'); + heading.textContent = `[${entry.level}] ${formatTimestamp(entry.timestamp)} ${entry.source}/${entry.stage}: ${entry.message}`; + item.append(heading); + + if (entry.stack) { + const stack = document.createElement('pre'); + stack.textContent = entry.stack; + item.append(stack); + } + + list.append(item); + } + + container.append(list); + body.append(container); +} + +async function refreshDiagnostics() { + renderDiagnostics(await getDiagnostics()); +} + +refreshDiagnostics(); + +if (chrome.storage && chrome.storage.onChanged) { + chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName === 'local' && changes) refreshDiagnostics(); + }); +} diff --git a/lib/debug/debug.html b/lib/debug/debug.html new file mode 100644 index 0000000000..0d3f976f16 --- /dev/null +++ b/lib/debug/debug.html @@ -0,0 +1,15 @@ + + + + + RES Safari Diagnostics + + + +
+

RES Safari Diagnostics

+

Loading diagnostics.

+
+ + + diff --git a/lib/debug/report.js b/lib/debug/report.js new file mode 100644 index 0000000000..7d59100fe1 --- /dev/null +++ b/lib/debug/report.js @@ -0,0 +1,77 @@ +/* @flow */ + +import type { DiagnosticEntry } from '../environment/utils/diagnostics'; + +export type DiagnosticReportContext = {| + buildTarget: string, + browser: string, + browserVersion: string, + extensionId: string, + generatedAt?: number, + issueURL?: string, + os: string, + pageURL: string, + userAgent: string, + version: string, +|}; + +function formatReportTimestamp(timestamp: number): string { + try { + return new Date(timestamp).toISOString(); + } catch (error) { + return String(timestamp); + } +} + +export function formatDiagnosticReport( + { + buildTarget, + browser, + browserVersion, + extensionId, + generatedAt = Date.now(), + issueURL = '', + os, + pageURL, + userAgent, + version, + }: DiagnosticReportContext, + entries: DiagnosticEntry[], +): string { + const lines = [ + 'RES Safari Diagnostics Report', + `Generated: ${formatReportTimestamp(generatedAt)}`, + '', + 'Environment', + `- RES Version: ${version}`, + `- Build Target: ${buildTarget}`, + `- Extension ID: ${extensionId}`, + `- Browser: ${browser} ${browserVersion}`, + `- OS: ${os}`, + `- Page: ${pageURL}`, + `- User Agent: ${userAgent}`, + ]; + + if (issueURL) { + lines.push(`- GitHub Safari Beta Issue Template: ${issueURL}`); + } + + lines.push('', 'Diagnostics'); + + if (!entries.length) { + lines.push('- No diagnostics have been recorded.'); + return lines.join('\n'); + } + + for (const entry of entries) { + lines.push(`[${entry.level}] ${formatReportTimestamp(entry.timestamp)} ${entry.source}/${entry.stage}: ${entry.message}`); + + if (entry.stack) { + lines.push(entry.stack); + } + + lines.push(''); + } + + return lines.join('\n').trimEnd(); +} diff --git a/lib/environment/background/__tests__/action.js b/lib/environment/background/__tests__/action.js new file mode 100644 index 0000000000..883a795fc3 --- /dev/null +++ b/lib/environment/background/__tests__/action.js @@ -0,0 +1,60 @@ +/* @flow */ + +import test from 'ava'; + +import { callActionMethod, handleToolbarClick } from '../action.js'; + +test('callActionMethod returns false when the action method is unavailable', t => { + t.false(callActionMethod({}, 'disable', 1)); +}); + +test('callActionMethod invokes available action methods', t => { + let invoked = false; + + t.true(callActionMethod({ + enable(tabId) { + invoked = tabId === 3; + }, + }, 'enable', 3)); + t.true(invoked); +}); + +test('handleToolbarClick records bootstrap failures and opens diagnostics', async t => { + const stages = []; + let diagnosticsOpened = false; + + await handleToolbarClick(5, { + openDiagnosticsPage: () => Promise.resolve().then(() => { + diagnosticsOpened = true; + }), + reportError: stage => { + stages.push(stage); + }, + sendClickMessage: () => Promise.reject(new Error('No receiver')), + bootstrapActionClick: () => Promise.reject(new Error('Bootstrap failed')), + supportsActionClickBootstrap: true, + }); + + t.deepEqual(stages, ['toolbar-click', 'toolbar-bootstrap']); + t.true(diagnosticsOpened); +}); + +test('handleToolbarClick opens diagnostics when Safari does not provide a tab id', async t => { + const stages = []; + let diagnosticsOpened = false; + + await handleToolbarClick(null, { + openDiagnosticsPage: () => Promise.resolve().then(() => { + diagnosticsOpened = true; + }), + reportError: stage => { + stages.push(stage); + }, + sendClickMessage: () => Promise.resolve(), + bootstrapActionClick: () => Promise.resolve(), + supportsActionClickBootstrap: true, + }); + + t.deepEqual(stages, ['toolbar-click:no-tab']); + t.true(diagnosticsOpened); +}); diff --git a/lib/environment/background/action.js b/lib/environment/background/action.js new file mode 100644 index 0000000000..9cb232326b --- /dev/null +++ b/lib/environment/background/action.js @@ -0,0 +1,57 @@ +/* @flow */ + +type ToolbarClickOptions = {| + openDiagnosticsPage: () => Promise, + reportError: (stage: string, error: Error) => mixed, + sendClickMessage: (tabId: number) => Promise, + bootstrapActionClick: (tabId: number) => Promise, + supportsActionClickBootstrap: boolean, +|}; + +export function callActionMethod(actionApi: any, methodName: string, ...args: mixed[]): boolean { + if (!actionApi || typeof actionApi[methodName] !== 'function') return false; + actionApi[methodName](...args); + return true; +} + +export async function handleToolbarClick(tabId: ?number, { + openDiagnosticsPage, + reportError, + sendClickMessage, + bootstrapActionClick, + supportsActionClickBootstrap, +}: ToolbarClickOptions): Promise { + if (!tabId) { + reportError('toolbar-click:no-tab', new Error('Toolbar click did not include a tab id.')); + + try { + await openDiagnosticsPage(); + } catch (openError) { + reportError('toolbar-click:no-tab:open-diagnostics', openError); + } + + return; + } + + try { + await sendClickMessage(tabId); + return; + } catch (error) { + reportError('toolbar-click', error); + } + + if (!supportsActionClickBootstrap) return; + + try { + await bootstrapActionClick(tabId); + await sendClickMessage(tabId); + } catch (error) { + reportError('toolbar-bootstrap', error); + + try { + await openDiagnosticsPage(); + } catch (openError) { + reportError('toolbar-bootstrap:open-diagnostics', openError); + } + } +} diff --git a/lib/environment/background/auth.js b/lib/environment/background/auth.js index 949b4284bb..3e941be9db 100644 --- a/lib/environment/background/auth.js +++ b/lib/environment/background/auth.js @@ -1,28 +1,27 @@ /* @flow */ +import { isFirefox, shouldUseBackgroundRedirectAuth } from '../utils/capabilities'; import { apiToPromise } from '../utils/api'; import { addListener } from './messaging'; addListener('authFlow', ({ domain, clientId, scope, interactive }) => { - const redirectUri = process.env.BUILD_TARGET !== 'firefox' ? 'https://redditenhancementsuite.com/oauth' : chrome.identity.getRedirectURL(); + const redirectUri = !isFirefox ? 'https://redditenhancementsuite.com/oauth' : chrome.identity.getRedirectURL(); const url = new URL(domain); url.searchParams.set('client_id', clientId); url.searchParams.set('scope', scope); url.searchParams.set('response_type', 'token'); url.searchParams.set('redirect_uri', redirectUri); - if (process.env.BUILD_TARGET !== 'firefox') { + if (!isFirefox) { // Chrome supports chrome.identity.launchAuthFlow. // However--and quite inexplicably--the auth process is performed in a separate context whose cookies // are cleared whenever the browser is restarted, making noninteractive auth pretty much useless. // Instead, fully emulate the flow in the main context, where users will remain logged in when // they expect to be, i.e., as long as they're logged into the main site. // As a bonus, we can use redditenhancementsuite.com as the redirect instead of chromiumapp.org. - if (interactive) { - return emulateAuthFlowInNewWindow(url.href, redirectUri); - } else { - return emulateAuthFlowInBackground(url.href); - } - } else if (process.env.BUILD_TARGET === 'firefox') { + if (interactive) return emulateAuthFlowInNewWindow(url.href, redirectUri); + if (shouldUseBackgroundRedirectAuth()) return emulateAuthFlowInBackground(url.href); + throw new Error('Background redirect auth is not supported for this browser.'); + } else if (isFirefox) { // Firefox correctly supports chrome.identity.launchAuthFlow. return apiToPromise(chrome.identity.launchWebAuthFlow)({ url: url.href, interactive }); } diff --git a/lib/environment/background/diagnosticsBootstrap.js b/lib/environment/background/diagnosticsBootstrap.js new file mode 100644 index 0000000000..38831b3ee5 --- /dev/null +++ b/lib/environment/background/diagnosticsBootstrap.js @@ -0,0 +1,11 @@ +/* @flow */ + +import { installGlobalDiagnosticHandlers, reportDiagnostic } from '../utils/diagnostics'; + +installGlobalDiagnosticHandlers('background'); +reportDiagnostic({ + level: 'info', + message: 'Background diagnostics bootstrap installed.', + source: 'background', + stage: 'startup', +}); diff --git a/lib/environment/background/download.js b/lib/environment/background/download.js index 4dac275815..540506be7c 100644 --- a/lib/environment/background/download.js +++ b/lib/environment/background/download.js @@ -1,7 +1,9 @@ /* @flow */ +import { isFirefox, shouldUseControlledDownloads } from '../utils/capabilities'; import { addListener } from './messaging'; addListener('download', ({ url, filename }, { tab: { incognito } }) => { - chrome.downloads.download({ url, filename, ...(process.env.BUILD_TARGET !== 'firefox' ? {} : { incognito }) }); + if (!shouldUseControlledDownloads() || !chrome.downloads) return; + chrome.downloads.download({ url, filename, ...(isFirefox ? { incognito } : {}) }); }); diff --git a/lib/environment/background/history.js b/lib/environment/background/history.js index c470e9208a..b46c1e868e 100644 --- a/lib/environment/background/history.js +++ b/lib/environment/background/history.js @@ -1,13 +1,16 @@ /* @flow */ +import { supportsHistory } from '../utils/capabilities'; import { apiToPromise } from '../utils/api'; import { addListener } from './messaging'; addListener('addURLToHistory', url => { + if (!supportsHistory || !chrome.history) return; chrome.history.addUrl({ url }); }); addListener('isURLVisited', async url => { + if (!supportsHistory || !chrome.history) return false; const visits = await apiToPromise(chrome.history.getVisits)({ url }); return visits.length > 0; }); diff --git a/lib/environment/background/messaging.js b/lib/environment/background/messaging.js index 85c3649359..1f5e81de39 100644 --- a/lib/environment/background/messaging.js +++ b/lib/environment/background/messaging.js @@ -2,14 +2,25 @@ import { createMessageHandler } from '../utils/messaging'; import { apiToPromise } from '../utils/api'; +import { isSafari } from '../utils/capabilities'; +import { sendTabMessageForBrowser } from '../utils/messageTransport'; -const _sendMessage = apiToPromise(chrome.tabs.sendMessage); +const callbackSendMessage = apiToPromise(chrome.tabs.sendMessage); + +function sendTabMessage(tabId, obj) { + return sendTabMessageForBrowser(tabId, obj, { + isSafari, + callbackSendMessage, + browserTabs: typeof browser === 'object' ? browser.tabs : null, + chromeTabsSendMessage: (nextTabId, payload) => chrome.tabs.sendMessage(nextTabId, payload), + }); +} const { _handleMessage, sendMessage, addListener, -} = createMessageHandler((obj, tabId) => _sendMessage(tabId, obj)); +} = createMessageHandler((obj, tabId) => sendTabMessage(tabId, obj)); chrome.runtime.onMessage.addListener((obj, sender, sendResponse) => _handleMessage(obj, sendResponse, sender)); diff --git a/lib/environment/background/pageAction.js b/lib/environment/background/pageAction.js index 55fa678017..91e5590e0e 100644 --- a/lib/environment/background/pageAction.js +++ b/lib/environment/background/pageAction.js @@ -1,29 +1,113 @@ /* @flow */ +import { apiToPromise } from '../utils/api'; +import { isFirefox, supportsActionClickBootstrap } from '../utils/capabilities'; +import { reportDiagnostic } from '../utils/diagnostics'; +import { callActionMethod, handleToolbarClick } from './action'; import { addListener, sendMessage } from './messaging'; -(process.env.BUILD_TARGET === 'firefox' ? chrome.pageAction : chrome.action).onClicked.addListener(tab => { - sendMessage('pageActionClick', undefined, tab.id); -}); +const actionApi = isFirefox ? chrome.pageAction : chrome.action; +const missingActionMethods = new Set(); +let reportedMissingTabContext = false; + +async function bootstrapActionClick(tabId: number) { + await apiToPromise(chrome.scripting.insertCSS)({ + target: { tabId, allFrames: true }, + files: ['res.css'], + }); + + await apiToPromise(chrome.scripting.executeScript)({ + target: { tabId, allFrames: true }, + files: ['foreground.entry.js'], + }); +} + +function reportMissingActionMethod(methodName: string) { + if (missingActionMethods.has(methodName)) return; + missingActionMethods.add(methodName); + + reportDiagnostic({ + level: 'warn', + message: `Ignoring unavailable toolbar action method: ${methodName}`, + source: 'background', + stage: 'pageAction', + }); +} + +function callSafely(methodName: string, ...args: mixed[]) { + if (!callActionMethod(actionApi, methodName, ...args)) { + reportMissingActionMethod(methodName); + return false; + } + + return true; +} + +async function openDiagnosticsPage() { + await apiToPromise(chrome.tabs.create)({ + active: true, + url: chrome.runtime.getURL('debug.html'), + }); +} + +if (actionApi && actionApi.onClicked && typeof actionApi.onClicked.addListener === 'function') { + actionApi.onClicked.addListener(tab => handleToolbarClick(tab.id, { + openDiagnosticsPage, + reportError(stage, error) { + console.error(`Failed to handle ${stage}:`, error); + reportDiagnostic({ + level: 'error', + message: error.message, + source: 'background', + stack: error.stack || '', + stage, + }); + }, + sendClickMessage: tabId => sendMessage('pageActionClick', undefined, tabId), + bootstrapActionClick, + supportsActionClickBootstrap, + })); +} else { + reportDiagnostic({ + level: 'warn', + message: 'Toolbar click listener API is unavailable.', + source: 'background', + stage: 'pageAction', + }); +} addListener('pageAction', ({ operation, state }, { tab }) => { + if (!tab || !tab.id) { + if (!reportedMissingTabContext) { + reportedMissingTabContext = true; + reportDiagnostic({ + level: 'warn', + message: 'Received pageAction request without a sender tab. Ignoring.', + source: 'background', + stage: 'pageAction', + }); + } + + return; + } + switch (operation) { case 'show': - (process.env.BUILD_TARGET === 'firefox' ? chrome.pageAction.show : chrome.action.enable)(tab.id); - (process.env.BUILD_TARGET === 'firefox' ? chrome.pageAction : chrome.action).setIcon({ + callSafely(isFirefox ? 'show' : 'enable', tab.id); + callSafely('setIcon', { tabId: tab.id, path: { '19': state ? 'css-on-small.png' : 'css-off-small.png', // eslint-disable-line quote-props '38': state ? 'css-on.png' : 'css-off.png', // eslint-disable-line quote-props }, }); - (process.env.BUILD_TARGET === 'firefox' ? chrome.pageAction : chrome.action).setTitle({ + callSafely('setTitle', { tabId: tab.id, title: state ? 'Subreddit Style On' : 'Subreddit Style Off', }); break; case 'hide': - (process.env.BUILD_TARGET === 'firefox' ? chrome.pageAction.hide : chrome.action.disable)(tab.id); + callSafely(isFirefox ? 'hide' : 'disable', tab.id); break; default: throw new Error(`Invalid action operation: ${operation}`); diff --git a/lib/environment/foreground/__tests__/id.js b/lib/environment/foreground/__tests__/id.js new file mode 100644 index 0000000000..228d01b597 --- /dev/null +++ b/lib/environment/foreground/__tests__/id.js @@ -0,0 +1,33 @@ +/* @flow */ + +import test from 'ava'; + +import { getOptionsURL, getURL } from '../id.js'; + +function installChrome(t) { + const previousChrome = global.chrome; + global.chrome = { + runtime: { + base: 'safari-web-extension://example/', + getURL(path) { + return `${this.base}${path}`; + }, + id: 'example', + }, + }; + t.teardown(() => { + global.chrome = previousChrome; + }); +} + +test.serial('getURL preserves the runtime context', t => { + installChrome(t); + + t.is(getURL('options.html'), 'safari-web-extension://example/options.html'); +}); + +test.serial('getOptionsURL builds against the extension page URL', t => { + installChrome(t); + + t.is(getOptionsURL('#test').href, 'safari-web-extension://example/options.html#test'); +}); diff --git a/lib/environment/foreground/auth.js b/lib/environment/foreground/auth.js index ab4f3586f5..fbff4c61e0 100644 --- a/lib/environment/foreground/auth.js +++ b/lib/environment/foreground/auth.js @@ -1,5 +1,6 @@ /* @flow */ +import { shouldUseBackgroundRedirectAuth } from '../utils/capabilities'; import { sendMessage } from './messaging'; import * as Permissions from './permissions'; @@ -27,11 +28,14 @@ export async function launchAuthFlow({ } let responseUrl; - try { - responseUrl = await sendMessage('authFlow', { domain, clientId, scope, interactive: false }); - } catch (e) { - console.error('Noninteractive auth failed:', e); - + if (shouldUseBackgroundRedirectAuth()) { + try { + responseUrl = await sendMessage('authFlow', { domain, clientId, scope, interactive: false }); + } catch (e) { + console.error('Noninteractive auth failed:', e); + responseUrl = await sendMessage('authFlow', { domain, clientId, scope, interactive: true }); + } + } else { responseUrl = await sendMessage('authFlow', { domain, clientId, scope, interactive: true }); } diff --git a/lib/environment/foreground/context.js b/lib/environment/foreground/context.js index 2b7649f94a..2b218386ca 100644 --- a/lib/environment/foreground/context.js +++ b/lib/environment/foreground/context.js @@ -5,7 +5,6 @@ import { loggedInUserHash, } from '../../utils/user'; import { contentStart } from '../../utils/pagePhases'; -import { waitForEvent } from '../../utils/dom'; export const data: {| userHash: ?string, @@ -28,10 +27,29 @@ if (location.protocol.startsWith('http')) { }); } -export function retrieveFromParent() { - if (window === window.parent) return Promise.resolve(); +export function retrieveFromParent(timeoutMs?: number = 0): Promise { + if (window === window.parent) return Promise.resolve(false); - return waitForEvent(window, 'message').then(({ data: { context } }: any) => { - Object.assign(data, context); + return new Promise(resolve => { + let timeoutId; + + const handleMessage = ({ data: message }: MessageEvent) => { + if (!message || typeof message !== 'object' || !message.context) return; + + window.removeEventListener('message', handleMessage); + if (timeoutId) clearTimeout(timeoutId); + + Object.assign(data, message.context); + resolve(true); + }; + + window.addEventListener('message', handleMessage); + + if (timeoutMs > 0) { + timeoutId = setTimeout(() => { + window.removeEventListener('message', handleMessage); + resolve(false); + }, timeoutMs); + } }); } diff --git a/lib/environment/foreground/diagnosticsBootstrap.js b/lib/environment/foreground/diagnosticsBootstrap.js new file mode 100644 index 0000000000..587495bc04 --- /dev/null +++ b/lib/environment/foreground/diagnosticsBootstrap.js @@ -0,0 +1,14 @@ +/* @flow */ + +import { getExecutionContextSource, installGlobalDiagnosticHandlers, installURLDiagnosticGuard, reportDiagnostic } from '../utils/diagnostics'; + +const source = getExecutionContextSource(); + +installGlobalDiagnosticHandlers(source); +installURLDiagnosticGuard(source); +reportDiagnostic({ + level: 'info', + message: 'Page diagnostics bootstrap installed.', + source, + stage: 'startup', +}); diff --git a/lib/environment/foreground/download.js b/lib/environment/foreground/download.js index 9e790cf8fe..dde194a725 100644 --- a/lib/environment/foreground/download.js +++ b/lib/environment/foreground/download.js @@ -1,12 +1,20 @@ /* @flow */ +import { shouldUseControlledDownloads } from '../utils/capabilities'; import { sendMessage } from './messaging'; +import { openNewTab } from './tabs'; export function download(url: string, filename?: string) { + url = new URL(url, location.href).href; + + if (!shouldUseControlledDownloads()) { + openNewTab(url); + return; + } + // Firefox and Chrome is same-origin only sendMessage('download', { - // resolve relative URLs - url: new URL(url, location.href).href, + url, filename, }); } diff --git a/lib/environment/foreground/history.js b/lib/environment/foreground/history.js index 85d50e0f69..bd0a9f57b1 100644 --- a/lib/environment/foreground/history.js +++ b/lib/environment/foreground/history.js @@ -1,14 +1,16 @@ /* @flow */ +import { shouldUseExtensionHistory, supportsHistory } from '../utils/capabilities'; import { sendMessage } from './messaging'; import { isPrivateBrowsing } from './privateBrowsing'; export async function addURLToHistory(url: string): Promise { - if (isPrivateBrowsing()) return; + if (!shouldUseExtensionHistory(isPrivateBrowsing())) return; await sendMessage('addURLToHistory', url); } export function isURLVisited(url: string): Promise { + if (!supportsHistory) return Promise.resolve(false); return sendMessage('isURLVisited', url); } diff --git a/lib/environment/foreground/i18n.js b/lib/environment/foreground/i18n.js index f94efd9079..5df0e0c257 100644 --- a/lib/environment/foreground/i18n.js +++ b/lib/environment/foreground/i18n.js @@ -2,6 +2,7 @@ import { CACHED_LANG_KEY, CACHED_MESSAGES_KEY, CACHED_MESSAGES_TOKEN_KEY } from '../../constants/localStorage'; import { downcast } from '../../utils/flow'; +import { reportDiagnostic } from '../utils/diagnostics'; import { sendMessage } from './messaging'; const buildToken = downcast(process.env.buildToken, 'string'); @@ -36,8 +37,19 @@ function getRedditLocale() { let messages; +async function logI18nStage(stage: string, message: string) { + await reportDiagnostic({ + level: 'info', + message, + source: 'options', + stage, + }); +} + export async function _loadI18n(): Promise { + await logI18nStage('i18n', 'Resolving Reddit locale.'); const redditLocale = await getRedditLocale(); + await logI18nStage('i18n', `Resolved Reddit locale: ${String(redditLocale || '(none)')}.`); if (redditLocale && !SPECIAL_LANGUAGES.has(redditLocale)) locale = redditLocale; // fast path: i18n dictionary is cached @@ -58,7 +70,9 @@ export async function _loadI18n(): Promise { // slow path: wait for background page to send new locales // will be hit the first (ever) pageload on a new domain // or after clearing localStorage + await logI18nStage('i18n', 'Fetching locale dictionary from the background page.'); messages = await sendMessage('i18n', redditLocale); + await logI18nStage('i18n', 'Locale dictionary loaded.'); try { // Fails if remaining localStorage space is insufficient localStorage.setItem(CACHED_MESSAGES_KEY, JSON.stringify(messages)); diff --git a/lib/environment/foreground/id.js b/lib/environment/foreground/id.js index a48f912429..a35400ee84 100644 --- a/lib/environment/foreground/id.js +++ b/lib/environment/foreground/id.js @@ -4,7 +4,9 @@ export function getExtensionId(): string { return chrome.runtime.id; } -export const getURL = chrome.runtime.getURL; +export function getURL(path: string): string { + return chrome.runtime.getURL(path); +} export const getOptionsURL = (hash: string = '') => new URL(hash, getURL('options.html')); export const isOptionsPage = () => location.origin === getOptionsURL().origin; diff --git a/lib/environment/foreground/messaging.js b/lib/environment/foreground/messaging.js index 7a8c834df9..c3bbc5cdf0 100644 --- a/lib/environment/foreground/messaging.js +++ b/lib/environment/foreground/messaging.js @@ -2,14 +2,25 @@ import { createMessageHandler } from '../utils/messaging'; import { apiToPromise } from '../utils/api'; +import { isSafari } from '../utils/capabilities'; +import { sendRuntimeMessageForBrowser } from '../utils/messageTransport'; -const _sendMessage = apiToPromise(chrome.runtime.sendMessage); +const callbackSendMessage = apiToPromise(chrome.runtime.sendMessage); + +function sendRuntimeMessage(obj) { + return sendRuntimeMessageForBrowser(obj, { + isSafari, + callbackSendMessage, + browserRuntime: typeof browser === 'object' ? browser.runtime : null, + chromeRuntimeSendMessage: payload => chrome.runtime.sendMessage(payload), + }); +} const { _handleMessage, sendMessage, addListener, -} = createMessageHandler(obj => _sendMessage(obj)); +} = createMessageHandler(obj => sendRuntimeMessage(obj)); chrome.runtime.onMessage.addListener((obj, sender, sendResponse) => _handleMessage(obj, sendResponse)); diff --git a/lib/environment/foreground/privateBrowsing.js b/lib/environment/foreground/privateBrowsing.js index a54351b33a..dda78d42ec 100644 --- a/lib/environment/foreground/privateBrowsing.js +++ b/lib/environment/foreground/privateBrowsing.js @@ -1,5 +1,5 @@ /* @flow */ export function isPrivateBrowsing(): boolean { - return chrome.extension.inIncognitoContext; + return Boolean(chrome.extension && chrome.extension.inIncognitoContext); } diff --git a/lib/environment/utils/__tests__/api.js b/lib/environment/utils/__tests__/api.js new file mode 100644 index 0000000000..d0b5982914 --- /dev/null +++ b/lib/environment/utils/__tests__/api.js @@ -0,0 +1,54 @@ +/* @flow */ + +import test from 'ava'; + +import { apiToPromise } from '../api.js'; + +function installChrome(t) { + const previousChrome = global.chrome; + global.chrome = { runtime: { lastError: null } }; + t.teardown(() => { + global.chrome = previousChrome; + }); +} + +test.serial('apiToPromise resolves callback-based APIs', async t => { + installChrome(t); + + const value = await apiToPromise((input, callback) => { + callback(input + 1); + })((2: any)); + + t.is(value, 3); +}); + +test.serial('apiToPromise resolves promise-returning APIs', async t => { + installChrome(t); + + const value = await apiToPromise(input => Promise.resolve(input + 1))((2: any)); + + t.is(value, 3); +}); + +test.serial('apiToPromise rejects callback-based errors', async t => { + installChrome(t); + + const wrapped = apiToPromise((input, callback) => { + global.chrome.runtime.lastError = { message: `bad:${input}` }; + callback(); + global.chrome.runtime.lastError = null; + }); + + await t.throwsAsync(wrapped((2: any)), { message: 'bad:2' }); +}); + +test.serial('apiToPromise resolves multiple callback arguments as an array', async t => { + installChrome(t); + + const wrapped = apiToPromise(callback => { + callback('first', 'second'); + }); + + const value = await wrapped(); + t.deepEqual(value, ['first', 'second']); +}); diff --git a/lib/environment/utils/__tests__/capabilities.js b/lib/environment/utils/__tests__/capabilities.js new file mode 100644 index 0000000000..a7677bb9a6 --- /dev/null +++ b/lib/environment/utils/__tests__/capabilities.js @@ -0,0 +1,52 @@ +/* @flow */ + +import test from 'ava'; + +import { + getBrowserCapabilities, + shouldUseBackgroundRedirectAuth, + shouldUseControlledDownloads, + shouldUseExtensionHistory, +} from '../capabilities.js'; + +test('Safari capability fallbacks', t => { + const safari = getBrowserCapabilities('safari'); + + t.false(safari.isFirefox); + t.true(safari.isSafari); + t.true(safari.supportsActionClickBootstrap); + t.false(shouldUseBackgroundRedirectAuth(safari)); + t.false(shouldUseControlledDownloads(safari)); + t.false(shouldUseExtensionHistory(false, safari)); +}); + +test('Firefox capability flags', t => { + const firefox = getBrowserCapabilities('firefox'); + + t.true(firefox.isFirefox); + t.false(firefox.isSafari); + t.false(firefox.supportsActionClickBootstrap); + t.true(shouldUseBackgroundRedirectAuth(firefox)); + t.true(shouldUseControlledDownloads(firefox)); + t.true(firefox.supportsHistory); + t.true(firefox.supportsControlledDownloads); + t.false(firefox.shouldBatchOptionStorage); +}); + +test('Chrome capability flags', t => { + const chrome = getBrowserCapabilities('chrome'); + + t.false(chrome.isFirefox); + t.false(chrome.isSafari); + t.false(chrome.supportsActionClickBootstrap); + t.true(shouldUseBackgroundRedirectAuth(chrome)); + t.true(shouldUseControlledDownloads(chrome)); + t.true(shouldUseExtensionHistory(false, chrome)); +}); + +test('History is disabled in private browsing even when supported', t => { + const chrome = getBrowserCapabilities('chrome'); + + t.true(shouldUseExtensionHistory(false, chrome)); + t.false(shouldUseExtensionHistory(true, chrome)); +}); diff --git a/lib/environment/utils/__tests__/diagnostics.js b/lib/environment/utils/__tests__/diagnostics.js new file mode 100644 index 0000000000..1f8cbf1a74 --- /dev/null +++ b/lib/environment/utils/__tests__/diagnostics.js @@ -0,0 +1,32 @@ +/* @flow */ + +import test from 'ava'; + +import { + appendDiagnosticEntry, + clearDiagnosticEntries, + DIAGNOSTICS_LIMIT, +} from '../diagnostics.js'; + +test('appendDiagnosticEntry keeps the newest entries within the limit', t => { + let entries = clearDiagnosticEntries(); + + for (const i of Array.from({ length: DIAGNOSTICS_LIMIT + 2 }, (_, index) => index)) { + entries = appendDiagnosticEntry(entries, { + level: 'error', + message: `message-${i}`, + source: 'options', + stack: '', + stage: 'startup', + timestamp: i, + }); + } + + t.is(entries.length, DIAGNOSTICS_LIMIT); + t.is(entries[0].message, 'message-2'); + t.is(entries[entries.length - 1].message, `message-${DIAGNOSTICS_LIMIT + 1}`); +}); + +test('clearDiagnosticEntries returns an empty diagnostics buffer', t => { + t.deepEqual(clearDiagnosticEntries(), []); +}); diff --git a/lib/environment/utils/__tests__/messageTransport.js b/lib/environment/utils/__tests__/messageTransport.js new file mode 100644 index 0000000000..a0d35fb609 --- /dev/null +++ b/lib/environment/utils/__tests__/messageTransport.js @@ -0,0 +1,144 @@ +/* @flow */ + +import test from 'ava'; + +import { + sendRuntimeMessageForBrowser, + sendTabMessageForBrowser, +} from '../messageTransport.js'; + +test('sendRuntimeMessageForBrowser uses the callback sender outside Safari', async t => { + let browserRuntimeCalled = false; + let chromeRuntimeCalled = false; + + const value = await sendRuntimeMessageForBrowser({ type: 'ping' }, { + isSafari: false, + callbackSendMessage: payload => Promise.resolve({ transport: 'callback', payload }), + browserRuntime: { + sendMessage: payload => { + browserRuntimeCalled = true; + return Promise.resolve(payload); + }, + }, + chromeRuntimeSendMessage: payload => { + chromeRuntimeCalled = true; + return payload; + }, + }); + + t.deepEqual(value, { transport: 'callback', payload: { type: 'ping' } }); + t.false(browserRuntimeCalled); + t.false(chromeRuntimeCalled); +}); + +test('sendRuntimeMessageForBrowser uses browser.runtime.sendMessage on Safari when available', async t => { + const value = await sendRuntimeMessageForBrowser({ type: 'ping' }, { + isSafari: true, + callbackSendMessage: () => { + throw new Error('callback sender should not be used on Safari'); + }, + browserRuntime: { + sendMessage: payload => Promise.resolve({ transport: 'browser', payload }), + }, + chromeRuntimeSendMessage: () => { + throw new Error('chrome runtime sender should not be used when browser.runtime is available'); + }, + }); + + t.deepEqual(value, { transport: 'browser', payload: { type: 'ping' } }); +}); + +test('sendRuntimeMessageForBrowser falls back to chrome.runtime.sendMessage promises on Safari', async t => { + const value = await sendRuntimeMessageForBrowser({ type: 'ping' }, { + isSafari: true, + callbackSendMessage: () => { + throw new Error('callback sender should not be used on Safari'); + }, + browserRuntime: null, + chromeRuntimeSendMessage: payload => Promise.resolve({ transport: 'chrome-promise', payload }), + }); + + t.deepEqual(value, { transport: 'chrome-promise', payload: { type: 'ping' } }); +}); + +test('sendRuntimeMessageForBrowser rejects when Safari does not expose a promise-based sender', async t => { + await t.throwsAsync( + sendRuntimeMessageForBrowser({ type: 'ping' }, { + isSafari: true, + callbackSendMessage: () => { + throw new Error('callback sender should not be used on Safari'); + }, + browserRuntime: null, + chromeRuntimeSendMessage: () => undefined, + }), + { message: 'Safari runtime.sendMessage did not return a Promise.' }, + ); +}); + +test('sendTabMessageForBrowser uses the callback sender outside Safari', async t => { + let browserTabsCalled = false; + let chromeTabsCalled = false; + + const value = await sendTabMessageForBrowser(7, { type: 'ping' }, { + isSafari: false, + callbackSendMessage: (tabId, payload) => Promise.resolve({ transport: 'callback', tabId, payload }), + browserTabs: { + sendMessage: () => { + browserTabsCalled = true; + return Promise.resolve(null); + }, + }, + chromeTabsSendMessage: () => { + chromeTabsCalled = true; + return null; + }, + }); + + t.deepEqual(value, { transport: 'callback', tabId: 7, payload: { type: 'ping' } }); + t.false(browserTabsCalled); + t.false(chromeTabsCalled); +}); + +test('sendTabMessageForBrowser uses browser.tabs.sendMessage on Safari when available', async t => { + const value = await sendTabMessageForBrowser(7, { type: 'ping' }, { + isSafari: true, + callbackSendMessage: () => { + throw new Error('callback sender should not be used on Safari'); + }, + browserTabs: { + sendMessage: (tabId, payload) => Promise.resolve({ transport: 'browser', tabId, payload }), + }, + chromeTabsSendMessage: () => { + throw new Error('chrome tabs sender should not be used when browser.tabs is available'); + }, + }); + + t.deepEqual(value, { transport: 'browser', tabId: 7, payload: { type: 'ping' } }); +}); + +test('sendTabMessageForBrowser falls back to chrome.tabs.sendMessage promises on Safari', async t => { + const value = await sendTabMessageForBrowser(7, { type: 'ping' }, { + isSafari: true, + callbackSendMessage: () => { + throw new Error('callback sender should not be used on Safari'); + }, + browserTabs: null, + chromeTabsSendMessage: (tabId, payload) => Promise.resolve({ transport: 'chrome-promise', tabId, payload }), + }); + + t.deepEqual(value, { transport: 'chrome-promise', tabId: 7, payload: { type: 'ping' } }); +}); + +test('sendTabMessageForBrowser rejects when Safari does not expose a promise-based tab sender', async t => { + await t.throwsAsync( + sendTabMessageForBrowser(7, { type: 'ping' }, { + isSafari: true, + callbackSendMessage: () => { + throw new Error('callback sender should not be used on Safari'); + }, + browserTabs: null, + chromeTabsSendMessage: () => undefined, + }), + { message: 'Safari tabs.sendMessage did not return a Promise.' }, + ); +}); diff --git a/lib/environment/utils/api.js b/lib/environment/utils/api.js index 821a08410c..13be6563d3 100644 --- a/lib/environment/utils/api.js +++ b/lib/environment/utils/api.js @@ -3,12 +3,41 @@ export function apiToPromise(func: (...args: mixed[]) => void): (...args: mixed[]) => Promise { return (...args) => new Promise((resolve, reject) => { - func(...args, (...results) => { + let settled = false; + const settle = (callback, value) => { + if (settled) return; + settled = true; + callback(value); + }; + const callback = (...results) => { if (chrome.runtime.lastError) { - reject(new Error(chrome.runtime.lastError.message)); + settle(reject, new Error(chrome.runtime.lastError.message)); } else { - resolve(results.length > 1 ? results : results[0]); + settle(resolve, results.length > 1 ? results : results[0]); } - }); + }; + + let result; + try { + result = func(...args, callback); + } catch (error) { + settle(reject, error); + return; + } + + if (result && typeof result.then === 'function') { + result.then( + value => { + settle(resolve, value); + }, + error => { + if (chrome.runtime.lastError) { + settle(reject, new Error(chrome.runtime.lastError.message)); + } else { + settle(reject, error); + } + }, + ); + } }); } diff --git a/lib/environment/utils/capabilities.js b/lib/environment/utils/capabilities.js new file mode 100644 index 0000000000..9ae2b7f08e --- /dev/null +++ b/lib/environment/utils/capabilities.js @@ -0,0 +1,58 @@ +/* @flow */ + +export type BrowserCapabilities = {| + buildTarget: string, + isFirefox: boolean, + isSafari: boolean, + supportsActionClickBootstrap: boolean, + supportsBackgroundRedirectAuth: boolean, + supportsControlledDownloads: boolean, + supportsCookieStoreContext: boolean, + supportsHistory: boolean, + shouldBatchOptionStorage: boolean, +|}; + +export function getBuildTarget(buildTarget: ?string = process.env.BUILD_TARGET): string { + return buildTarget || 'chrome'; +} + +export function getBrowserCapabilities(buildTarget: ?string = process.env.BUILD_TARGET): BrowserCapabilities { + const target = getBuildTarget(buildTarget); + const isFirefox = target === 'firefox'; + const isSafari = target === 'safari'; + + return { + buildTarget: target, + isFirefox, + isSafari, + supportsActionClickBootstrap: isSafari, + supportsBackgroundRedirectAuth: !isSafari, + supportsControlledDownloads: !isSafari, + supportsCookieStoreContext: isFirefox, + supportsHistory: !isSafari, + shouldBatchOptionStorage: !isFirefox, + }; +} + +export function shouldUseBackgroundRedirectAuth(capabilities: BrowserCapabilities = browserCapabilities): boolean { + return capabilities.supportsBackgroundRedirectAuth; +} + +export function shouldUseControlledDownloads(capabilities: BrowserCapabilities = browserCapabilities): boolean { + return capabilities.supportsControlledDownloads; +} + +export function shouldUseExtensionHistory(privateBrowsing: boolean, capabilities: BrowserCapabilities = browserCapabilities): boolean { + return capabilities.supportsHistory && !privateBrowsing; +} + +export const browserCapabilities = getBrowserCapabilities(); +export const buildTarget = browserCapabilities.buildTarget; +export const isFirefox = browserCapabilities.isFirefox; +export const isSafari = browserCapabilities.isSafari; +export const supportsActionClickBootstrap = browserCapabilities.supportsActionClickBootstrap; +export const supportsBackgroundRedirectAuth = browserCapabilities.supportsBackgroundRedirectAuth; +export const supportsControlledDownloads = browserCapabilities.supportsControlledDownloads; +export const supportsCookieStoreContext = browserCapabilities.supportsCookieStoreContext; +export const supportsHistory = browserCapabilities.supportsHistory; +export const shouldBatchOptionStorage = browserCapabilities.shouldBatchOptionStorage; diff --git a/lib/environment/utils/diagnostics.js b/lib/environment/utils/diagnostics.js new file mode 100644 index 0000000000..39bcab806d --- /dev/null +++ b/lib/environment/utils/diagnostics.js @@ -0,0 +1,221 @@ +/* @flow */ + +import { apiToPromise } from './api.js'; +import { getBuildTarget } from './capabilities.js'; + +export type DiagnosticLevel = 'info' | 'warn' | 'error'; + +export type DiagnosticEntry = {| + level: DiagnosticLevel, + message: string, + source: string, + stack: string, + stage: string, + timestamp: number, +|}; + +type DiagnosticInput = {| + level?: DiagnosticLevel, + message: string, + source?: string, + stack?: string, + stage: string, + timestamp?: number, +|}; + +export const DIAGNOSTICS_KEY = 'RES.safariDiagnostics'; +export const DIAGNOSTICS_LIMIT = 75; + +export function shouldCollectDiagnostics(buildTarget: ?string = process.env.BUILD_TARGET): boolean { + return getBuildTarget(buildTarget) === 'safari'; +} + +export function clearDiagnosticEntries(): DiagnosticEntry[] { + return []; +} + +export function appendDiagnosticEntry(entries: DiagnosticEntry[], entry: DiagnosticEntry, limit: number = DIAGNOSTICS_LIMIT): DiagnosticEntry[] { + return [...entries, entry].slice(-limit); +} + +export function getExecutionContextSource(): string { + if (typeof location === 'object') { + if (location.pathname.endsWith('/options.html')) return 'options'; + if (location.pathname.endsWith('/debug.html')) return 'debug'; + if (location.protocol.startsWith('http')) return 'foreground'; + } + + if ( + typeof self === 'object' && + self && + self.constructor && + self.constructor.name === 'ServiceWorkerGlobalScope' + ) { + return 'background'; + } + + return 'extension'; +} + +function normalizeDiagnosticEntry({ + level = 'error', + message, + source = getExecutionContextSource(), + stack = '', + stage, + timestamp = Date.now(), +}: DiagnosticInput): DiagnosticEntry { + return { + level, + message, + source, + stack, + stage, + timestamp, + }; +} + +function getStorageArea() { + if (typeof chrome !== 'object' || !chrome.storage || !chrome.storage.local) return null; + return chrome.storage.local; +} + +async function readStoredDiagnostics(): Promise { + const storageArea = getStorageArea(); + if (!storageArea) return clearDiagnosticEntries(); + + const get = apiToPromise((keys, callback) => storageArea.get(keys, callback)); + const stored = await get({ [DIAGNOSTICS_KEY]: clearDiagnosticEntries() }); + return Array.isArray(stored[DIAGNOSTICS_KEY]) ? stored[DIAGNOSTICS_KEY] : clearDiagnosticEntries(); +} + +async function writeStoredDiagnostics(entries: DiagnosticEntry[]): Promise { + const storageArea = getStorageArea(); + if (!storageArea) return; + + const set = apiToPromise((items, callback) => storageArea.set(items, callback)); + await set({ [DIAGNOSTICS_KEY]: entries }); +} + +function describeErrorLike(reason: mixed): {| message: string, stack: string |} { + if (reason instanceof Error) { + return { + message: reason.message || reason.name || 'Unhandled error', + stack: reason.stack || '', + }; + } + + if (typeof reason === 'string') { + return { message: reason, stack: '' }; + } + + if (reason && typeof reason === 'object') { + const { message, stack } = (reason: any); + return { + message: typeof message === 'string' ? message : JSON.stringify(reason), + stack: typeof stack === 'string' ? stack : '', + }; + } + + return { + message: String(reason || 'Unknown error'), + stack: '', + }; +} + +export async function reportDiagnostic(entryInput: DiagnosticInput): Promise { + if (!shouldCollectDiagnostics()) return null; + + const entry = normalizeDiagnosticEntry(entryInput); + + try { + const diagnostics = await readStoredDiagnostics(); + await writeStoredDiagnostics(appendDiagnosticEntry(diagnostics, entry)); + } catch (error) { + console.error('Failed to persist Safari diagnostics', error); + } + + return entry; +} + +export function getDiagnostics(): Promise { + if (!shouldCollectDiagnostics()) return Promise.resolve(clearDiagnosticEntries()); + return readStoredDiagnostics(); +} + +export async function clearDiagnostics(): Promise { + if (!shouldCollectDiagnostics()) return; + await writeStoredDiagnostics(clearDiagnosticEntries()); +} + +export function installGlobalDiagnosticHandlers(source: string, stage: string = 'runtime') { + if (!shouldCollectDiagnostics()) return; + + const target: any = + typeof self === 'object' ? self : + typeof window === 'object' ? window : + null; + + if (!target || typeof target.addEventListener !== 'function') return; + + if (!target.__resDiagnosticsSources) target.__resDiagnosticsSources = new Set(); + if (target.__resDiagnosticsSources.has(source)) return; + target.__resDiagnosticsSources.add(source); + + target.addEventListener('error', event => { + const detail = describeErrorLike(event.error || event); + const locationHint = [event.filename, event.lineno, event.colno].filter(v => v !== undefined && v !== null).join(':'); + + reportDiagnostic({ + level: 'error', + message: locationHint ? `${detail.message} (${locationHint})` : detail.message, + source, + stack: detail.stack, + stage, + }); + }); + + target.addEventListener('unhandledrejection', event => { + const detail = describeErrorLike(event.reason); + reportDiagnostic({ + level: 'error', + message: detail.message, + source, + stack: detail.stack, + stage: `${stage}:unhandledrejection`, + }); + }); +} + +export function installURLDiagnosticGuard(source: string) { + if (!shouldCollectDiagnostics()) return; + if (typeof window !== 'object' && typeof self !== 'object') return; + + const target: any = + typeof window === 'object' ? window : + typeof self === 'object' ? self : + null; + + if (!target || typeof target.URL !== 'function' || target.__resURLDiagnosticGuardInstalled) return; + target.__resURLDiagnosticGuardInstalled = true; + + const NativeURL = target.URL; + const DiagnosticURL = new Proxy(NativeURL, { + construct(urlConstructor, args, newTarget) { + try { + return Reflect.construct(urlConstructor, args, newTarget); + } catch (error) { + reportDiagnostic({ + level: 'error', + message: `URL constructor failed for arguments: ${JSON.stringify(args.map(value => String(value)))}`, + source, + stack: error && error.stack ? error.stack : '', + stage: 'runtime:url', + }); + throw error; + } + }, + }); + + target.URL = DiagnosticURL; +} diff --git a/lib/environment/utils/messageTransport.js b/lib/environment/utils/messageTransport.js new file mode 100644 index 0000000000..8d47905d12 --- /dev/null +++ b/lib/environment/utils/messageTransport.js @@ -0,0 +1,55 @@ +/* @flow */ + +type RuntimeTransportOptions = {| + isSafari: boolean, + callbackSendMessage: (payload: mixed) => Promise, + browserRuntime?: ?{| + sendMessage?: (payload: mixed) => Promise, + |}, + chromeRuntimeSendMessage: (payload: mixed) => mixed, +|}; + +type TabTransportOptions = {| + isSafari: boolean, + callbackSendMessage: (tabId: number, payload: mixed) => Promise, + browserTabs?: ?{| + sendMessage?: (tabId: number, payload: mixed) => Promise, + |}, + chromeTabsSendMessage: (tabId: number, payload: mixed) => mixed, +|}; + +export function sendRuntimeMessageForBrowser(payload: mixed, { + isSafari, + callbackSendMessage, + browserRuntime, + chromeRuntimeSendMessage, +}: RuntimeTransportOptions): Promise { + if (!isSafari) return callbackSendMessage(payload); + + if (browserRuntime && typeof browserRuntime.sendMessage === 'function') { + return browserRuntime.sendMessage(payload); + } + + const result = chromeRuntimeSendMessage(payload); + if (result && typeof result.then === 'function') return result; + + return Promise.reject(new Error('Safari runtime.sendMessage did not return a Promise.')); +} + +export function sendTabMessageForBrowser(tabId: number, payload: mixed, { + isSafari, + callbackSendMessage, + browserTabs, + chromeTabsSendMessage, +}: TabTransportOptions): Promise { + if (!isSafari) return callbackSendMessage(tabId, payload); + + if (browserTabs && typeof browserTabs.sendMessage === 'function') { + return browserTabs.sendMessage(tabId, payload); + } + + const result = chromeTabsSendMessage(tabId, payload); + if (result && typeof result.then === 'function') return result; + + return Promise.reject(new Error('Safari tabs.sendMessage did not return a Promise.')); +} diff --git a/lib/environment/utils/messaging.js b/lib/environment/utils/messaging.js index b9d19ea03c..e676056079 100644 --- a/lib/environment/utils/messaging.js +++ b/lib/environment/utils/messaging.js @@ -1,5 +1,7 @@ /* @flow */ +import { getExecutionContextSource, reportDiagnostic } from './diagnostics.js'; + type MessagePayload = {| type: string, data?: mixed, @@ -66,6 +68,13 @@ export function createMessageHandler(_sendMessage: Internal response = listener(data, context); } catch (e) { console.error(e); + reportDiagnostic({ + level: 'error', + message: e.message, + source: getExecutionContextSource(), + stack: e.stack || '', + stage: `message:${type}`, + }); sendResponse({ error: { message: e.message, stack: e.stack } }); return false; } @@ -76,6 +85,13 @@ export function createMessageHandler(_sendMessage: Internal data => sendResponse({ data }), e => { console.error(e); + reportDiagnostic({ + level: 'error', + message: e.message, + source: getExecutionContextSource(), + stack: e.stack || '', + stage: `message:${type}`, + }); sendResponse({ error: { message: e.message, stack: e.stack } }); }, ); diff --git a/lib/foreground.entry.js b/lib/foreground.entry.js index a76987f0d0..ffe7b6779b 100644 --- a/lib/foreground.entry.js +++ b/lib/foreground.entry.js @@ -1,8 +1,19 @@ /* @flow */ +import './environment/foreground/diagnosticsBootstrap'; +import { installRequestIdleCallback } from './utils/requestIdleCallback'; import { RES_DISABLED_HASH, RES_SETTINGS_HASH, RES_SETTINGS_REDIRECT_TO_STANDALONE_HASH } from './constants/urlHashes'; import { init } from './core/init'; import { getURL } from './environment/foreground/id'; +import { reportDiagnostic } from './environment/utils/diagnostics'; + +installRequestIdleCallback(); +reportDiagnostic({ + level: 'info', + message: 'Foreground entry loaded.', + source: 'foreground', + stage: 'startup', +}); const blockers = []; @@ -31,6 +42,18 @@ if (window !== window.parent && (new URL(location.href)).searchParams.get('embed if (blockers.length) { console.warn('Preventing initalization of RES:', blockers); + reportDiagnostic({ + level: 'warn', + message: `Preventing initialization of RES: ${blockers.join(' ')}`, + source: 'foreground', + stage: 'startup', + }); } else { + reportDiagnostic({ + level: 'info', + message: 'Calling RES init().', + source: 'foreground', + stage: 'startup', + }); init(); } diff --git a/lib/modules/backupAndRestore/providers/Dropbox.js b/lib/modules/backupAndRestore/providers/Dropbox.js index 9393713e02..1902ae51f9 100644 --- a/lib/modules/backupAndRestore/providers/Dropbox.js +++ b/lib/modules/backupAndRestore/providers/Dropbox.js @@ -1,5 +1,6 @@ /* @flow */ +import { supportsBackgroundRedirectAuth } from '../../../environment/utils/capabilities'; import { ajax, launchAuthFlow } from '../../../environment'; import { Alert } from '../../../utils'; import { Provider } from './Provider'; @@ -9,7 +10,7 @@ const FILE = '/res-storage.json'; export class Dropbox extends Provider { static key = 'dropbox'; static text = 'Dropbox'; - static supportsAutomaticBackups = true; + static supportsAutomaticBackups = supportsBackgroundRedirectAuth; accessToken: string; diff --git a/lib/modules/backupAndRestore/providers/GoogleDrive.js b/lib/modules/backupAndRestore/providers/GoogleDrive.js index 83e37b0e46..0e75c4e0e8 100644 --- a/lib/modules/backupAndRestore/providers/GoogleDrive.js +++ b/lib/modules/backupAndRestore/providers/GoogleDrive.js @@ -1,5 +1,6 @@ /* @flow */ +import { supportsBackgroundRedirectAuth } from '../../../environment/utils/capabilities'; import { ajax, launchAuthFlow } from '../../../environment'; import { Alert } from '../../../utils'; import { Provider } from './Provider'; @@ -10,7 +11,7 @@ const FOLDER = 'appDataFolder'; export class GoogleDrive extends Provider { static key = 'googledrive'; static text = 'Google Drive'; - static supportsAutomaticBackups = true; + static supportsAutomaticBackups = supportsBackgroundRedirectAuth; accessToken: string; diff --git a/lib/modules/backupAndRestore/providers/OneDrive.js b/lib/modules/backupAndRestore/providers/OneDrive.js index 8353806076..9099d337d8 100644 --- a/lib/modules/backupAndRestore/providers/OneDrive.js +++ b/lib/modules/backupAndRestore/providers/OneDrive.js @@ -1,5 +1,6 @@ /* @flow */ +import { supportsBackgroundRedirectAuth } from '../../../environment/utils/capabilities'; import { ajax, launchAuthFlow } from '../../../environment'; import { Alert } from '../../../utils'; import { Provider } from './Provider'; @@ -9,7 +10,7 @@ const FILE = 'res-storage.json'; export class OneDrive extends Provider { static key = 'onedrive'; static text = 'OneDrive'; - static supportsAutomaticBackups = true; + static supportsAutomaticBackups = supportsBackgroundRedirectAuth; accessToken: string; diff --git a/lib/modules/filteReddit/cases.js b/lib/modules/filteReddit/cases.js index a045408caf..ef7e6fce8d 100644 --- a/lib/modules/filteReddit/cases.js +++ b/lib/modules/filteReddit/cases.js @@ -1,6 +1,7 @@ /* @flow */ import { pickBy } from 'lodash-es'; +import { supportsHistory } from '../../environment/utils/capabilities'; import { asyncFilter, fastAsync, randomHash } from '../../utils'; import type { BuilderValue } from '../../core/module'; import { Case } from './Case'; @@ -236,6 +237,12 @@ export function createAdHoc(type: string, getConditions: *, variant: *, context: } const available: { [type: string]: Class } = {}; +const unsupportedPostCases = new Set(['commentsOpened', 'isVisited']); + +function getSupportedPostCases() { + if (supportsHistory) return postCases; + return Object.fromEntries(Object.entries(postCases).filter(([key]) => !unsupportedPostCases.has(key))); +} function getUniqueTypeName(name: any): string { if (typeof name !== 'string') name = ''; @@ -276,7 +283,7 @@ export function populatePrimitives(types: Array<*> = ['post', 'comment', 'browse fill({ group: Group }, 'post', 'comment', 'browse'); - if (types.includes('post')) fill(postCases, 'post'); + if (types.includes('post')) fill(getSupportedPostCases(), 'post'); if (types.includes('comment')) fill(commentCases, 'comment'); if (types.includes('browse')) fill(browseCases, 'browse'); } diff --git a/lib/modules/hosts/__tests__/vreddit.js b/lib/modules/hosts/__tests__/vreddit.js new file mode 100644 index 0000000000..dc93f5631a --- /dev/null +++ b/lib/modules/hosts/__tests__/vreddit.js @@ -0,0 +1,102 @@ +/* @flow */ + +import test from 'ava'; + +import { + extractCaptionFromPostMetadata, + getPostCaption, +} from '../vredditCaption.js'; +import { + buildSafariHlsVideoSources, + buildVredditVideoSources, +} from '../vredditSource.js'; + +test('extractCaptionFromPostMetadata prefers the original crosspost caption', t => { + const caption = extractCaptionFromPostMetadata({ + selftext_html: '

outer

', + crosspost_parent_list: [{ + selftext_html: '

inner

', + }], + }); + + t.is(caption, 'inner'); +}); + +test('getPostCaption resolves quickly to an empty caption when metadata stalls', async t => { + const caption = await getPostCaption('t3_abc123', () => new Promise(() => {}), { + setTimeoutFn(callback) { + callback(); + return null; + }, + }); + + t.is(caption, ''); +}); + +test('getPostCaption returns caption text when metadata resolves before the timeout', async t => { + const caption = await getPostCaption('t3_abc123', () => Promise.resolve({ + selftext_html: '

combat footage caption

', + }), { + setTimeoutFn: () => null, + }); + + t.is(caption, 'combat footage caption'); +}); + +test('buildVredditVideoSources prefers HLS on Safari for audio-bearing videos', t => { + const result = buildVredditVideoSources({ + buildTarget: 'safari', + dashManifest: '', + hasAudio: true, + hlsAvailable: true, + id: 'abc123', + mp4Sources: ['https://v.redd.it/abc123/video.mp4'], + }); + + t.is(result.transport, 'hls'); + t.false(result.muted); + t.deepEqual(result.sources, [{ + source: 'https://v.redd.it/abc123/HLSPlaylist.m3u8', + type: 'application/vnd.apple.mpegurl', + }]); +}); + +test('buildSafariHlsVideoSources returns a Safari-native HLS source tuple', t => { + const result = buildSafariHlsVideoSources('https://v.redd.it/abc123/HLSPlaylist.m3u8'); + + t.deepEqual(result, { + muted: false, + sources: [{ + source: 'https://v.redd.it/abc123/HLSPlaylist.m3u8', + type: 'application/vnd.apple.mpegurl', + }], + transport: 'hls', + }); +}); + +test('buildVredditVideoSources falls back to DASH outside Safari and to MP4 for muted videos', t => { + t.is(buildVredditVideoSources({ + buildTarget: 'chrome', + dashManifest: '', + hasAudio: true, + hlsAvailable: true, + id: 'abc123', + mp4Sources: ['https://v.redd.it/abc123/video.mp4'], + }).transport, 'dash'); + + const muted = buildVredditVideoSources({ + buildTarget: 'safari', + dashManifest: '', + hasAudio: false, + hlsAvailable: true, + id: 'abc123', + mp4Sources: ['https://v.redd.it/abc123/video.mp4'], + }); + + t.is(muted.transport, 'mp4'); + t.true(muted.muted); + t.deepEqual(muted.sources, [{ + source: 'https://v.redd.it/abc123/video.mp4', + type: 'video/mp4', + }]); +}); diff --git a/lib/modules/hosts/vreddit.js b/lib/modules/hosts/vreddit.js index fd6a73706e..d09147f18e 100644 --- a/lib/modules/hosts/vreddit.js +++ b/lib/modules/hosts/vreddit.js @@ -3,12 +3,48 @@ import { difference, sortBy } from 'lodash-es'; import { Host } from '../../core/host'; import { ajax } from '../../environment'; +import { reportDiagnostic } from '../../environment/utils/diagnostics'; import { getPostMetadata } from '../../utils'; +import { getPostCaption } from './vredditCaption.js'; +import { + buildSafariHlsVideoSources, + buildVredditVideoSources, + getVredditPlaylistUrls, +} from './vredditSource.js'; + +const DASH_PLAYLIST_TIMEOUT_MS = 2500; +const HLS_CHECK_TIMEOUT_MS = 1500; + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timeoutId; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + + return Promise.race([promise, timeout]).finally(() => { + if (timeoutId) clearTimeout(timeoutId); + }); +} + +async function isSafariHlsAvailable(hlsPlaylistUrl: string): Promise { + if (process.env.BUILD_TARGET !== 'safari') return false; + + try { + await withTimeout( + ajax({ url: hlsPlaylistUrl, type: 'raw' }), + HLS_CHECK_TIMEOUT_MS, + 'Timed out while checking the v.redd.it HLS playlist.', + ); + return true; + } catch (error) { + return false; + } +} export default new Host('vreddit', { name: 'v.redd.it', domains: ['v.redd.it'], - permissions: ['https://*.redd.it/*'], + permissions: process.env.BUILD_TARGET === 'safari' ? [] : ['https://*.redd.it/*'], attribution: false, options: { forceReplaceNativeExpando: { @@ -27,9 +63,60 @@ export default new Host('vreddit', { }, detect({ pathname }, thing) { return thing && { fullname: thing.getFullname(), id: pathname.slice(1) }; }, async handleLink(href, { fullname, id }) { - const originalPlaylistUrl = `https://v.redd.it/${id}/DASHPlaylist.mpd`; - const mpd = await ajax({ url: originalPlaylistUrl }); + const captionPromise = getPostCaption(fullname, getPostMetadata); + const { dash: originalPlaylistUrl, hls: hlsPlaylistUrl } = getVredditPlaylistUrls(id); + const isSafari = process.env.BUILD_TARGET === 'safari'; + + reportDiagnostic({ + level: 'info', + message: `Starting v.redd.it media resolution for ${id}.`, + source: 'showImages', + stage: 'video:resolve', + }); + + if (isSafari) { + reportDiagnostic({ + level: 'info', + message: `Checking Safari HLS availability for v.redd.it ${id}.`, + source: 'showImages', + stage: 'video:resolve', + }); + const hlsAvailable = await isSafariHlsAvailable(hlsPlaylistUrl); + if (hlsAvailable) { + reportDiagnostic({ + level: 'info', + message: `Using Safari HLS transport for v.redd.it ${id} before DASH resolution.`, + source: 'showImages', + stage: 'video:transport', + }); + + return { + type: 'VIDEO', + loop: true, + caption: await captionPromise, + ...buildSafariHlsVideoSources(hlsPlaylistUrl), + }; + } + } + + reportDiagnostic({ + level: 'info', + message: `Fetching the v.redd.it DASH playlist for ${id}.`, + source: 'showImages', + stage: 'video:resolve', + }); + const mpd = await withTimeout( + ajax({ url: originalPlaylistUrl }), + DASH_PLAYLIST_TIMEOUT_MS, + 'Timed out while loading the v.redd.it DASH playlist.', + ); const manifest = new DOMParser().parseFromString(mpd, 'text/xml'); + reportDiagnostic({ + level: 'info', + message: `Parsed the v.redd.it DASH manifest for ${id}.`, + source: 'showImages', + stage: 'video:resolve', + }); const minBandwidth = parseInt(this.options.minimumVideoBandwidth.value, 10) * 1000; const reps = Array.from(manifest.querySelectorAll('Representation[frameRate]')); @@ -49,31 +136,31 @@ export default new Host('vreddit', { baseURLElement.textContent = (new URL(baseURLElement.textContent, originalPlaylistUrl)).href; } - // Audio is in a seperate stream, and requires a heavy dash dependency to add to the video - const muted = !manifest.querySelector('AudioChannelConfiguration'); + const hasAudio = Boolean(manifest.querySelector('AudioChannelConfiguration')); + const hlsAvailable = hasAudio && isSafari ? await isSafariHlsAvailable(hlsPlaylistUrl) : false; - if (!videoSourcesByBandwidth.length) throw new Error('Video has no valid sources'); + if (!videoSourcesByBandwidth.length && !hlsAvailable) throw new Error('Video has no valid sources'); - // Get postMetadata for video caption - let postMetadata = await getPostMetadata({ id: fullname.replace('t3_', '') }); - // Pick out original metadata if this is a crosspost - if (postMetadata.crosspost_parent_list && postMetadata.crosspost_parent_list.length > 0) { - postMetadata = postMetadata.crosspost_parent_list[0]; - } + const { muted, sources, transport } = buildVredditVideoSources({ + buildTarget: process.env.BUILD_TARGET, + dashManifest: (new XMLSerializer()).serializeToString(manifest), + hasAudio, + hlsAvailable, + id, + mp4Sources: videoSourcesByBandwidth.map(rep => rep.querySelector('BaseURL').textContent), + }); - const sources = (muted && id) ? - videoSourcesByBandwidth.map(rep => ({ - source: rep.querySelector('BaseURL').textContent, - type: 'video/mp4', - })) : [{ - source: (new XMLSerializer()).serializeToString(manifest), - type: 'application/dash+xml', - }]; + reportDiagnostic({ + level: 'info', + message: `Selected ${transport} transport for v.redd.it ${id}.`, + source: 'showImages', + stage: 'video:transport', + }); return { type: 'VIDEO', loop: true, - caption: postMetadata.selftext_html && postMetadata.selftext_html.replace(/<\/?p>/g, ''), + caption: await captionPromise, muted, sources, }; diff --git a/lib/modules/hosts/vredditCaption.js b/lib/modules/hosts/vredditCaption.js new file mode 100644 index 0000000000..93d971b2ea --- /dev/null +++ b/lib/modules/hosts/vredditCaption.js @@ -0,0 +1,37 @@ +/* @flow */ + +export function extractCaptionFromPostMetadata(postMetadata: { [string]: any }): string { + const originalPostMetadata = postMetadata.crosspost_parent_list && postMetadata.crosspost_parent_list.length > 0 ? + postMetadata.crosspost_parent_list[0] : + postMetadata; + + return originalPostMetadata.selftext_html ? originalPostMetadata.selftext_html.replace(/<\/?p>/g, '') : ''; +} + +export function getPostCaption( + fullname: ?string, + fetchPostMetadata: ({ id: string }) => Promise<{ [string]: any }>, + { + timeoutMs = 250, + setTimeoutFn = setTimeout, + }: {| + timeoutMs?: number, + setTimeoutFn?: (callback: () => void, timeout: number) => mixed, + |} = {}, +): Promise { + if (!fullname) return Promise.resolve(''); + + const id = fullname.replace('t3_', ''); + const captionPromise = fetchPostMetadata({ id }) + .then(extractCaptionFromPostMetadata) + .catch(error => { + console.warn(`showImages: could not load post metadata for v.redd.it ${id}`, error); + return ''; + }); + + const timeoutPromise = new Promise(resolve => { + setTimeoutFn(() => { resolve(''); }, timeoutMs); + }); + + return Promise.race([captionPromise, timeoutPromise]); +} diff --git a/lib/modules/hosts/vredditSource.js b/lib/modules/hosts/vredditSource.js new file mode 100644 index 0000000000..d99eebbeb3 --- /dev/null +++ b/lib/modules/hosts/vredditSource.js @@ -0,0 +1,70 @@ +/* @flow */ + +export const HLS_PLAYLIST_TYPE = 'application/vnd.apple.mpegurl'; +export const DASH_PLAYLIST_TYPE = 'application/dash+xml'; + +export function getVredditPlaylistUrls(id: string): {| + dash: string, + hls: string, +|} { + return { + dash: `https://v.redd.it/${id}/DASHPlaylist.mpd`, + hls: `https://v.redd.it/${id}/HLSPlaylist.m3u8`, + }; +} + +export function buildSafariHlsVideoSources(hlsPlaylistUrl: string): {| + muted: boolean, + sources: Array<{| + source: string, + type: string, + |}>, + transport: 'hls', +|} { + return { + muted: false, + sources: [{ source: hlsPlaylistUrl, type: HLS_PLAYLIST_TYPE }], + transport: 'hls', + }; +} + +export function buildVredditVideoSources({ + buildTarget = process.env.BUILD_TARGET, + dashManifest, + hasAudio, + hlsAvailable = false, + id, + mp4Sources, +}: {| + buildTarget?: string, + dashManifest: string, + hasAudio: boolean, + hlsAvailable?: boolean, + id: string, + mp4Sources: string[], +|}): {| + muted: boolean, + sources: Array<{| + source: string, + type: string, + |}>, + transport: 'mp4' | 'hls' | 'dash', +|} { + if (buildTarget === 'safari' && hasAudio && hlsAvailable) { + return buildSafariHlsVideoSources(getVredditPlaylistUrls(id).hls); + } + + if (!hasAudio) { + return { + muted: true, + sources: mp4Sources.map(source => ({ source, type: 'video/mp4' })), + transport: 'mp4', + }; + } + + return { + muted: false, + sources: [{ source: dashManifest, type: DASH_PLAYLIST_TYPE }], + transport: 'dash', + }; +} diff --git a/lib/modules/settingsNavigation.js b/lib/modules/settingsNavigation.js index 0109c3efdc..f6be6d4863 100644 --- a/lib/modules/settingsNavigation.js +++ b/lib/modules/settingsNavigation.js @@ -2,6 +2,7 @@ import { RES_SETTINGS_HASH } from '../constants/urlHashes'; import { context, isOptionsPage, getOptionsURL, i18n, openNewTab } from '../environment'; +import { reportDiagnostic } from '../environment/utils/diagnostics'; import { string } from '../utils'; import { Module } from '../core/module'; import * as Modules from '../core/modules'; @@ -64,7 +65,21 @@ module.contentStart = () => { // Open settings links (regardless of hostname) document.body.addEventListener('click', (e: MouseEvent) => { if (e.ctrlKey) return; // Except when intentionally opened in a new tab - const url = e.target instanceof HTMLAnchorElement && new URL(e.target.href, location.origin); + let url; + if (e.target instanceof HTMLAnchorElement) { + try { + url = new URL(e.target.href, location.origin); + } catch (error) { + reportDiagnostic({ + level: 'warn', + message: error.message || 'Failed to parse settings link URL.', + source: 'foreground', + stack: error.stack || '', + stage: 'settingsNavigation:click', + }); + return; + } + } if (url && isSettingsUrl(url.href)) { update(url); e.stopImmediatePropagation(); @@ -121,7 +136,20 @@ export function makeUrlHash(moduleID?: string, optionKey?: string): string { } export function isSettingsUrl(href: string): boolean { - const { origin, hash } = new URL(href, location.origin); + let origin; + let hash; + try { + ({ origin, hash } = new URL(href, location.origin)); + } catch (error) { + reportDiagnostic({ + level: 'warn', + message: error.message || 'Failed to parse settings URL.', + source: isOptionsPage() ? 'options' : 'foreground', + stack: error.stack || '', + stage: 'settingsNavigation:isSettingsUrl', + }); + return false; + } const sameSite = origin === getOptionsURL().origin || origin.split('.').slice(-2).join('.') === context.origin.split('.').slice(-2).join('.'); diff --git a/lib/modules/showImages.js b/lib/modules/showImages.js index f181928794..932be20705 100644 --- a/lib/modules/showImages.js +++ b/lib/modules/showImages.js @@ -57,6 +57,8 @@ import { Permissions, Storage, } from '../environment'; +import { reportDiagnostic } from '../environment/utils/diagnostics'; +import { supportsControlledDownloads, supportsHistory } from '../environment/utils/capabilities'; import * as Modules from '../core/modules'; import * as Options from '../core/options'; import * as __hosts from './hosts'; @@ -78,6 +80,20 @@ import { expandos, activeExpandos, } from './showImages/expando'; +import { + canFallbackToNativeOnInitFailure, + canFallbackToNativeOnMediaFailure, + restoreNativeExpando, +} from './showImages/nativeFallback'; +import { hasOptionalPermissions } from './showImages/permissions'; +import { + createVideoBootstrapPromise, + EXPANDO_INIT_TIMEOUT_MS, + getVideoTransport, + shouldTreatStalledVideoAsFailure, + VIDEO_STALL_GRACE_MS, + withBootstrapTimeout, +} from './showImages/videoBootstrap'; import vreddit from './hosts/vreddit'; const siteModules: Map> = new Map( @@ -301,7 +317,7 @@ module.options = { }, sfwHistory: { title: 'showImagesSfwHistoryTitle', - dependsOn: options => options.markVisited.value, + dependsOn: options => options.markVisited.value && supportsHistory, type: 'enum', value: 'add', values: [{ @@ -836,6 +852,7 @@ function resolveMediaUrl(element, thing) { function promptSiteModulePermissions(siteModule) { const { name, permissions = [] } = siteModule; + if (!hasOptionalPermissions(permissions)) return Promise.resolve(); const urlStripRe = /((?:\w+\.)+\w+)(?=\/|$)/i; const message = string.html`
@@ -868,7 +885,7 @@ function promptSiteModulePermissions(siteModule) { } const generateSiteModuleLock = memoize(async siteModule => { - if (!siteModule.permissions || await Permissions.has(siteModule.permissions)) return; + if (!hasOptionalPermissions(siteModule.permissions) || await Permissions.has(siteModule.permissions)) return; let resolve, reject; return { @@ -904,10 +921,100 @@ export function getLinkExpando(link: HTMLAnchorElement): ?Expando { return linksMap.get(link); } +function getPlacedExpandoForElement(element: HTMLAnchorElement, thing: ?Thing, href: string): ?Expando { + const candidateButtons = []; + + if (!inText(element) && thing && thing.getTitleElement()) { + let sibling = element.parentElement && element.parentElement.nextElementSibling; + while (sibling && sibling.classList.contains('expando-button')) { + candidateButtons.push(sibling); + sibling = sibling.nextElementSibling; + } + } else { + const adjacentAnchor = $(element).add($(element).next('.keyNavAnnotation')).last()[0]; + const wrapper = adjacentAnchor && adjacentAnchor.nextElementSibling; + const button = wrapper && + wrapper.classList.contains('res-freetext-expando') && + wrapper.querySelector(Expando.expandoSelector); + if (button) candidateButtons.push(button); + } + + for (const button of candidateButtons) { + const expando = expandos.get(button); + if ( + expando instanceof Expando && + !expando.destroyed && + expando.href === href + ) { + return expando; + } + } +} + +function getReusableTextExpandoForThing(thing: ?Thing, href: string): ?Expando { + if (!thing) return; + + for (const expando of Expando.getTextExpandosFrom(thing)) { + if ( + !expando.destroyed && + expando.button && + document.contains(expando.button) && + expando.href === href + ) { + return expando; + } + } +} + +function reportSafariVideoDiagnostic({ + level = 'info', + message, + stage, + stack = '', +}: {| + level?: 'info' | 'warn' | 'error', + message: string, + stage: string, + stack?: string, +|}) { + reportDiagnostic({ + level, + message, + source: 'showImages', + stack, + stage, + }); +} + const inText = element => !!element.closest('.md, .search-result-footer'); async function checkElementForMedia(element: HTMLAnchorElement) { const thing = Thing.from(element); + const href = new URL(element.href, location.href).href; + const existingExpando = linksMap.get(element); + if (existingExpando) { + const isLiveExpando = + !existingExpando.destroyed && + existingExpando.button && + document.contains(existingExpando.button); + if (isLiveExpando) return existingExpando; + linksMap.delete(element); + } + + const placedExpando = getPlacedExpandoForElement(element, thing, href); + if (placedExpando) { + linksMap.set(element, placedExpando); + return placedExpando; + } + + if (inText(element)) { + const reusableTextExpando = getReusableTextExpandoForThing(thing, href); + if (reusableTextExpando) { + linksMap.set(element, reusableTextExpando); + return reusableTextExpando; + } + } + const entryExpando = !inText(element) && Expando.getEntryExpandoFrom(thing); const nativeExpando = entryExpando instanceof Expando ? null : entryExpando; @@ -956,10 +1063,40 @@ async function checkElementForMedia(element: HTMLAnchorElement) { try { if (lock) await lock.promise; // eslint-disable-line no-await-in-loop - await completeExpando(expando, thing, siteModule, detectResult); // eslint-disable-line no-await-in-loop + const completeExpandoPromise = completeExpando(expando, thing, siteModule, detectResult, { element, nativeExpando }); + if (process.env.BUILD_TARGET === 'safari') { + // eslint-disable-next-line no-await-in-loop + await withBootstrapTimeout(completeExpandoPromise, { + timeoutMs: EXPANDO_INIT_TIMEOUT_MS, + timeoutMessage: `Expando initialization timed out for ${siteModule.moduleID}.`, + }); + } else { + await completeExpandoPromise; // eslint-disable-line no-await-in-loop + } break; } catch (e) { console.error(`showImages: could not create expando for ${mediaUrl.href}`, e); + reportSafariVideoDiagnostic({ + level: 'error', + message: e.message || `Could not create expando for ${mediaUrl.href}.`, + stack: e.stack || '', + stage: 'video:init', + }); + + if (canFallbackToNativeOnInitFailure(siteModule.moduleID, Boolean(nativeExpando))) { + reportSafariVideoDiagnostic({ + level: 'warn', + message: `Falling back to the native expando for ${mediaUrl.href} after Safari initialization failed.`, + stage: 'video:fallback', + }); + restoreNativeExpando({ + expando, + nativeExpando, + cleanup: () => { linksMap.delete(element); }, + }); + break; + } + if (nativeExpando) nativeExpando.reattach(); expando.destroy(); linksMap.delete(element); @@ -981,8 +1118,48 @@ function placeExpando(expando, element, thing) { } } -async function completeExpando(expando, thing, siteModule, detectResult) { +function watchSafariVideoBootstrap({ expando, element, media, mediaOptions, nativeExpando, siteModule }) { + if (!(media instanceof Video) || !media.bootstrapReady) return; + + const transport = getVideoTransport(mediaOptions.sources); + + reportSafariVideoDiagnostic({ + message: `Attaching ${transport} video sources for ${siteModule.moduleID}.`, + stage: 'video:attach', + }); + + media.bootstrapReady.then(() => { + reportSafariVideoDiagnostic({ + message: `${transport} video became playable for ${siteModule.moduleID}.`, + stage: 'video:ready', + }); + }).catch(error => { + reportSafariVideoDiagnostic({ + level: 'error', + message: error.message || `The ${transport} video bootstrap failed for ${siteModule.moduleID}.`, + stack: error.stack || '', + stage: 'video:ready', + }); + + if (!canFallbackToNativeOnMediaFailure(mediaOptions.type, Boolean(nativeExpando))) return; + + reportSafariVideoDiagnostic({ + level: 'warn', + message: `Falling back to the native expando for ${expando.href} after ${transport} video bootstrap failed.`, + stage: 'video:fallback', + }); + + restoreNativeExpando({ + expando, + nativeExpando, + cleanup: () => { linksMap.delete(element); }, + }); + }); +} + +async function completeExpando(expando, thing, siteModule, detectResult, { element, nativeExpando }) { const mediaOptions = await siteModule.handleLink(expando.href, detectResult); + if (expando.destroyed) return mediaOptions; if (mediaOptions.title && thing && string.areSimilar(mediaOptions.title, thing.getTitle())) { mediaOptions.title = ''; @@ -1004,6 +1181,7 @@ async function completeExpando(expando, thing, siteModule, detectResult) { buttonInfo: getMediaButtonInfo(mediaOptions), generateMedia() { const media = generateMedia(mediaOptions, { href: expando.href }); + watchSafariVideoBootstrap({ expando, element, media, mediaOptions, nativeExpando, siteModule }); if (module.options.crossposts.value === 'withMetadata' && thing && thing.isCrosspost()) { media.element.prepend(crosspostMetadataTemplate(thing.element.dataset)); } @@ -1011,6 +1189,7 @@ async function completeExpando(expando, thing, siteModule, detectResult) { return media; }, }); + if (expando.destroyed || !expando.button || !expando.box) return mediaOptions; expando.button.setAttribute('data-host', siteModule.moduleID); expando.box.setAttribute('data-host', siteModule.moduleID); @@ -1059,6 +1238,8 @@ async function completeExpando(expando, thing, siteModule, detectResult) { expando.expand(); } } + + return mediaOptions; } function updateParentHeight(e) { @@ -1160,6 +1341,7 @@ export class Media { element: HTMLElement; ready: ?Promise; + bootstrapReady: ?Promise; onAttach: ?() => void; isAttached(): boolean { return document.body.contains(this.element); } @@ -1333,6 +1515,11 @@ export class Media { updateRotation(); break; case 'download': + if (!supportsControlledDownloads) { + download(downloadUrl); + break; + } + Permissions.request(['downloads']).then(() => { const re = /(?:\.([^.]+))?$/; const ext = re.exec(downloadUrl); @@ -1971,10 +2158,41 @@ class Video extends Media { const container = this.element.querySelector('.res-video-container'); const msgError = this.element.querySelector('.res-video-error'); + let stalledErrorTimeoutId; + const hideError = () => { + empty(msgError); + msgError.hidden = true; + }; const displayError = message => { msgError.hidden = false; msgError.textContent = `Could not play video: ${message}`; }; + const clearTransientStallError = () => { + if (stalledErrorTimeoutId !== undefined) { + clearTimeout(stalledErrorTimeoutId); + stalledErrorTimeoutId = undefined; + } + + if (msgError.textContent === 'Could not play video: Loading stalled') { + hideError(); + } + }; + const scheduleStalledError = callback => { + clearTransientStallError(); + stalledErrorTimeoutId = setTimeout(() => { + stalledErrorTimeoutId = undefined; + if (!shouldTreatStalledVideoAsFailure({ + currentTime: this.video.currentTime, + ended: this.video.ended, + paused: this.video.paused, + readyState: this.video.readyState, + })) { + return; + } + + callback(); + }, VIDEO_STALL_GRACE_MS); + }; const sourceElements = filterMap(sources, v => { if (this.video.canPlayType(v.type)) { @@ -2009,7 +2227,10 @@ class Video extends Media { } }); - if (!sourceElements.length) { + const hasPlayableSources = sourceElements.length > 0; + const bootstrapFailureMessage = hasPlayableSources ? null : 'No playable sources were found'; + + if (!hasPlayableSources) { if (fallback) { return new Image({ // eslint-disable-line no-constructor-return type: 'IMAGE', @@ -2018,25 +2239,42 @@ class Video extends Media { credits, src: fallback, }, context); - } else { - displayError('No playable sources were found'); } - } - this.video.append(...sourceElements); + displayError(bootstrapFailureMessage); + } else { + this.video.append(...sourceElements); + } - this.video.addEventListener('play', () => { empty(msgError); msgError.hidden = true; }); - this.video.addEventListener('stalled', () => { displayError('Loading stalled'); }); + this.video.addEventListener('play', hideError); + for (const eventName of ['canplay', 'canplaythrough', 'playing', 'progress', 'timeupdate']) { + this.video.addEventListener(eventName, clearTransientStallError); + } + this.video.addEventListener('stalled', () => { + scheduleStalledError(() => { displayError('Loading stalled'); }); + }); this.video.addEventListener('error', () => { displayError('Unknown error'); }); if (reversed) this.reverse(); - this.ready = Promise.race([ - // 'ended' is not triggered when the video loops - waitForEvent(this.video, 'ended'), - waitForEvent(this.video, 'error'), - waitForEvent(this.video, 'canplaythrough'), - ]); + const waitForFailure = hasPlayableSources ? new Promise((_, reject) => { + this.video.addEventListener('error', () => reject(new Error('Unknown error')), { once: true }); + this.video.addEventListener('stalled', () => { + scheduleStalledError(() => reject(new Error('Loading stalled'))); + }); + }) : Promise.reject(new Error(bootstrapFailureMessage || 'No playable sources were found')); + + const waitForPlayable = ( + this.dashPlayer ? + this.dashPlayer.then(() => waitForEvent(this.video, 'canplay', 'canplaythrough')) : + waitForEvent(this.video, 'canplay', 'canplaythrough') + ); + + this.bootstrapReady = createVideoBootstrapPromise({ + waitForPlayable, + waitForFailure, + }); + this.ready = this.bootstrapReady.catch(() => undefined); const setPlayIcon = () => { if (!this.video.paused) this.element.setAttribute('playing', ''); @@ -2099,7 +2337,7 @@ class Video extends Media { this.setMaxSize(this.video); this.makeZoomable(this.video); - this.addControls(this.video, undefined, sourceElements[0].getAttribute('src')); + this.addControls(this.video, undefined, hasPlayableSources ? sourceElements[0].getAttribute('src') : undefined); this.makeMovable(container); this.keepVisible(container); this.makeIndependent(container); diff --git a/lib/modules/showImages/__tests__/nativeFallback.js b/lib/modules/showImages/__tests__/nativeFallback.js new file mode 100644 index 0000000000..a78a25ca23 --- /dev/null +++ b/lib/modules/showImages/__tests__/nativeFallback.js @@ -0,0 +1,61 @@ +/* @flow */ + +import test from 'ava'; +import { + canFallbackToNativeOnInitFailure, + canFallbackToNativeOnMediaFailure, + restoreNativeExpando, +} from '../nativeFallback.js'; + +test('canFallbackToNativeOnInitFailure only enables Safari vreddit fallback when native expando exists', t => { + t.true(canFallbackToNativeOnInitFailure('vreddit', true, 'safari')); + t.false(canFallbackToNativeOnInitFailure('vreddit', false, 'safari')); + t.false(canFallbackToNativeOnInitFailure('imgur', true, 'safari')); + t.false(canFallbackToNativeOnInitFailure('vreddit', true, 'chrome')); +}); + +test('canFallbackToNativeOnMediaFailure only enables Safari video fallback when native expando exists', t => { + t.true(canFallbackToNativeOnMediaFailure('VIDEO', true, 'safari')); + t.false(canFallbackToNativeOnMediaFailure('IMAGE', true, 'safari')); + t.false(canFallbackToNativeOnMediaFailure('VIDEO', false, 'safari')); + t.false(canFallbackToNativeOnMediaFailure('VIDEO', true, 'chrome')); +}); + +test('restoreNativeExpando reattaches and expands the native expando when the RES expando was active', t => { + const calls = []; + + t.true(restoreNativeExpando({ + expando: { + open: true, + expandWanted: false, + destroy() { calls.push('destroy'); }, + }, + nativeExpando: { + open: false, + reattach() { calls.push('reattach'); }, + expand() { calls.push('expand'); }, + }, + cleanup() { calls.push('cleanup'); }, + })); + + t.deepEqual(calls, ['destroy', 'cleanup', 'reattach', 'expand']); +}); + +test('restoreNativeExpando reattaches without expanding when the RES expando was not active', t => { + const calls = []; + + restoreNativeExpando({ + expando: { + open: false, + expandWanted: false, + destroy() { calls.push('destroy'); }, + }, + nativeExpando: { + open: false, + reattach() { calls.push('reattach'); }, + expand() { calls.push('expand'); }, + }, + }); + + t.deepEqual(calls, ['destroy', 'reattach']); +}); diff --git a/lib/modules/showImages/__tests__/permissions.js b/lib/modules/showImages/__tests__/permissions.js new file mode 100644 index 0000000000..ef0b7e0f34 --- /dev/null +++ b/lib/modules/showImages/__tests__/permissions.js @@ -0,0 +1,12 @@ +/* @flow */ + +import test from 'ava'; + +import { hasOptionalPermissions } from '../permissions.js'; + +test('hasOptionalPermissions only returns true for non-empty permission arrays', t => { + t.false(hasOptionalPermissions()); + t.false(hasOptionalPermissions(null)); + t.false(hasOptionalPermissions([])); + t.true(hasOptionalPermissions(['https://example.com/*'])); +}); diff --git a/lib/modules/showImages/__tests__/videoBootstrap.js b/lib/modules/showImages/__tests__/videoBootstrap.js new file mode 100644 index 0000000000..bc33dff5fa --- /dev/null +++ b/lib/modules/showImages/__tests__/videoBootstrap.js @@ -0,0 +1,87 @@ +/* @flow */ + +import test from 'ava'; +import { + createVideoBootstrapPromise, + EXPANDO_INIT_TIMEOUT_MS, + getVideoTransport, + shouldTreatStalledVideoAsFailure, + VIDEO_MIN_READY_STATE, + withBootstrapTimeout, +} from '../videoBootstrap.js'; + +test('getVideoTransport detects mp4, hls, dash, and unknown source strategies', t => { + t.is(getVideoTransport([{ type: 'video/mp4' }]), 'mp4'); + t.is(getVideoTransport([{ type: 'application/vnd.apple.mpegurl' }]), 'hls'); + t.is(getVideoTransport([{ type: 'application/dash+xml' }]), 'dash'); + t.is(getVideoTransport([]), 'unknown'); +}); + +test('shouldTreatStalledVideoAsFailure only flags unrecovered initial stalls', t => { + t.true(shouldTreatStalledVideoAsFailure({ + currentTime: 0, + paused: true, + readyState: VIDEO_MIN_READY_STATE - 1, + })); + t.false(shouldTreatStalledVideoAsFailure({ + currentTime: 1, + paused: false, + readyState: VIDEO_MIN_READY_STATE - 1, + })); + t.false(shouldTreatStalledVideoAsFailure({ + currentTime: 0, + paused: true, + readyState: VIDEO_MIN_READY_STATE, + })); + t.false(shouldTreatStalledVideoAsFailure({ + currentTime: 0, + ended: true, + paused: true, + readyState: VIDEO_MIN_READY_STATE - 1, + })); +}); + +test('createVideoBootstrapPromise resolves when playable media arrives before failure or timeout', async t => { + await t.notThrowsAsync(createVideoBootstrapPromise({ + waitForPlayable: Promise.resolve(), + waitForFailure: new Promise(() => {}), + })); +}); + +test('createVideoBootstrapPromise rejects when media bootstrap fails', async t => { + await t.throwsAsync(createVideoBootstrapPromise({ + waitForPlayable: new Promise(() => {}), + waitForFailure: Promise.reject(new Error('Loading stalled')), + }), { message: 'Loading stalled' }); +}); + +test('createVideoBootstrapPromise rejects when media bootstrap times out', async t => { + await t.throwsAsync(createVideoBootstrapPromise({ + waitForPlayable: new Promise(() => {}), + waitForFailure: new Promise(() => {}), + setTimeoutFn(callback) { + callback(); + return null; + }, + clearTimeoutFn() {}, + }), { message: 'Video did not become playable before the bootstrap timeout.' }); +}); + +test('withBootstrapTimeout resolves when the wrapped promise settles before timeout', async t => { + await t.notThrowsAsync(withBootstrapTimeout(Promise.resolve('ok'))); +}); + +test('withBootstrapTimeout preserves wrapped rejections', async t => { + await t.throwsAsync(withBootstrapTimeout(Promise.reject(new Error('broken'))), { message: 'broken' }); +}); + +test('withBootstrapTimeout rejects when the wrapped promise does not settle in time', async t => { + await t.throwsAsync(withBootstrapTimeout(new Promise(() => {}), { + timeoutMs: EXPANDO_INIT_TIMEOUT_MS, + setTimeoutFn(callback) { + callback(); + return null; + }, + clearTimeoutFn() {}, + }), { message: 'Expando initialization did not finish before the bootstrap timeout.' }); +}); diff --git a/lib/modules/showImages/expando.js b/lib/modules/showImages/expando.js index 69e8e1a537..96807c1469 100644 --- a/lib/modules/showImages/expando.js +++ b/lib/modules/showImages/expando.js @@ -89,6 +89,7 @@ export class Expando { open: boolean = false; expandWanted: boolean = false; + destroyed: boolean = false; expandCallbacks: OnExpandCallback[] = []; @@ -127,6 +128,8 @@ export class Expando { } updateButton() { + if (this.destroyed || !this.button) return; + let { mediaClass, title } = this.buttonInfo; if (this.lock) { @@ -153,6 +156,8 @@ export class Expando { } initialize(options: {| generateMedia: *, buttonInfo: *, types: * |}) { + if (this.destroyed || !this.button || !this.box) return; + this.generateMedia = options.generateMedia; this.buttonInfo = options.buttonInfo; this.types = options.types; @@ -167,10 +172,13 @@ export class Expando { } async setLock(lock: *) { + if (this.destroyed || !this.button) return; + this.lock = lock; this.updateButton(); await lock.promise; + if (this.destroyed || !this.button) return; this.lock = null; this.updateButton(); @@ -182,6 +190,8 @@ export class Expando { } expand() { + if (this.destroyed || !this.button || !this.box) return; + if (this.lock || !this.ready) { this.expandWanted = true; if (this.lock) this.lock.open(); @@ -205,6 +215,8 @@ export class Expando { } collapse() { + if (this.destroyed || !this.button || !this.box) return; + this.box.hidden = true; this.open = false; @@ -217,6 +229,8 @@ export class Expando { } attachMedia() { + if (this.destroyed || !this.box) return; + const wrapper = this.box.firstElementChild || document.createElement('div'); if (!this.generateMedia) throw new Error('Cannot attach media without `generateMedia`'); this.media = this.media || this.generateMedia(); @@ -232,6 +246,9 @@ export class Expando { } destroy() { + if (this.destroyed) return; + this.destroyed = true; + if (this.box) { this.box.remove(); delete this.box; @@ -249,7 +266,7 @@ export class Expando { this.media.element.remove(); delete this.media; } - if (this.button) { + if (!this.destroyed && this.button) { if (this.open) this.collapse(); else this.updateButton(); } diff --git a/lib/modules/showImages/nativeFallback.js b/lib/modules/showImages/nativeFallback.js new file mode 100644 index 0000000000..c4826be890 --- /dev/null +++ b/lib/modules/showImages/nativeFallback.js @@ -0,0 +1,35 @@ +/* @flow */ + +export function canFallbackToNativeOnInitFailure(siteModuleID: string, hasNativeExpando: boolean, buildTarget: string = process.env.BUILD_TARGET): boolean { + return buildTarget === 'safari' && + hasNativeExpando && + siteModuleID === 'vreddit'; +} + +export function canFallbackToNativeOnMediaFailure(mediaType: string, hasNativeExpando: boolean, buildTarget: string = process.env.BUILD_TARGET): boolean { + return buildTarget === 'safari' && + hasNativeExpando && + mediaType === 'VIDEO'; +} + +export function restoreNativeExpando({ + expando, + nativeExpando, + cleanup, +}: {| + expando: {| destroy: () => void, open?: boolean, expandWanted?: boolean |}, + nativeExpando: {| reattach: () => void, expand: () => void, open?: boolean |}, + cleanup?: () => void, +|}): boolean { + if (!nativeExpando) return false; + + const shouldExpand = Boolean(expando && (expando.open || expando.expandWanted)); + + expando.destroy(); + if (cleanup) cleanup(); + nativeExpando.reattach(); + + if (shouldExpand && !nativeExpando.open) nativeExpando.expand(); + + return true; +} diff --git a/lib/modules/showImages/permissions.js b/lib/modules/showImages/permissions.js new file mode 100644 index 0000000000..4490f3413a --- /dev/null +++ b/lib/modules/showImages/permissions.js @@ -0,0 +1,5 @@ +/* @flow */ + +export function hasOptionalPermissions(permissions: ?string[]): boolean { + return Array.isArray(permissions) && permissions.length > 0; +} diff --git a/lib/modules/showImages/videoBootstrap.js b/lib/modules/showImages/videoBootstrap.js new file mode 100644 index 0000000000..4a7f9e5f4f --- /dev/null +++ b/lib/modules/showImages/videoBootstrap.js @@ -0,0 +1,90 @@ +/* @flow */ + +export const VIDEO_BOOTSTRAP_TIMEOUT_MS = 5000; +export const EXPANDO_INIT_TIMEOUT_MS = 7000; +export const VIDEO_STALL_GRACE_MS = 1500; +export const VIDEO_MIN_READY_STATE = 3; + +export function getVideoTransport( + sources: Array<{| type: string |}> = [], +): 'mp4' | 'hls' | 'dash' | 'unknown' { + if (sources.some(({ type }) => /mpegurl/i.test(type))) return 'hls'; + if (sources.some(({ type }) => type === 'application/dash+xml')) return 'dash'; + if (sources.length && sources.every(({ type }) => type === 'video/mp4')) return 'mp4'; + return 'unknown'; +} + +export function shouldTreatStalledVideoAsFailure({ + currentTime = 0, + ended = false, + paused = true, + readyState = 0, + minimumReadyState = VIDEO_MIN_READY_STATE, +}: {| + currentTime?: number, + ended?: boolean, + paused?: boolean, + readyState?: number, + minimumReadyState?: number, +|} = {}): boolean { + return !ended && paused && currentTime <= 0 && readyState < minimumReadyState; +} + +export function createVideoBootstrapPromise({ + waitForPlayable, + waitForFailure, + timeoutMs = VIDEO_BOOTSTRAP_TIMEOUT_MS, + setTimeoutFn = setTimeout, + clearTimeoutFn = clearTimeout, + timeoutMessage = 'Video did not become playable before the bootstrap timeout.', +}: {| + waitForPlayable: Promise, + waitForFailure: Promise, + timeoutMs?: number, + setTimeoutFn?: (callback: () => void, timeout: number) => mixed, + clearTimeoutFn?: (timeoutId: mixed) => void, + timeoutMessage?: string, +|}): Promise { + let timeoutId; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeoutFn(() => { + reject(new Error(timeoutMessage)); + }, timeoutMs); + }); + + return Promise.race([ + waitForPlayable.then(() => {}), + waitForFailure, + timeoutPromise, + ]).finally(() => { + if (timeoutId !== undefined) clearTimeoutFn(timeoutId); + }); +} + +export function withBootstrapTimeout( + promise: Promise, + { + timeoutMs = EXPANDO_INIT_TIMEOUT_MS, + setTimeoutFn = setTimeout, + clearTimeoutFn = clearTimeout, + timeoutMessage = 'Expando initialization did not finish before the bootstrap timeout.', + }: {| + timeoutMs?: number, + setTimeoutFn?: (callback: () => void, timeout: number) => mixed, + clearTimeoutFn?: (timeoutId: mixed) => void, + timeoutMessage?: string, + |} = {}, +): Promise { + let timeoutId; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeoutFn(() => { + reject(new Error(timeoutMessage)); + }, timeoutMs); + }); + + return Promise.race([promise, timeoutPromise]).finally(() => { + if (timeoutId !== undefined) clearTimeoutFn(timeoutId); + }); +} diff --git a/lib/options/diagnosticsLink.entry.js b/lib/options/diagnosticsLink.entry.js new file mode 100644 index 0000000000..ff1aff9fc0 --- /dev/null +++ b/lib/options/diagnosticsLink.entry.js @@ -0,0 +1,8 @@ +/* @flow */ + +const link = document.getElementById('diagnostics-link'); +const runtime = window.chrome && window.chrome.runtime; + +if (link instanceof HTMLAnchorElement && runtime && runtime.getURL) { + link.href = runtime.getURL('debug.html'); +} diff --git a/lib/options/handleBlocking.js b/lib/options/handleBlocking.js index eaf5904993..6268942661 100644 --- a/lib/options/handleBlocking.js +++ b/lib/options/handleBlocking.js @@ -2,15 +2,19 @@ // The browser may block localStorage etc on embedded pages, preventing the settings console from rendering // Detect this early, so that settingsNavigation can open it in a new tab instead -if (window !== window.top) { +export function ensureStorageAvailable() { + if (window === window.top) return; + let blocked = true; try { // Storage is necessary for RES blocked = !Number.isInteger(localStorage.length + sessionStorage.length); } catch (e) { - if (blocked) { - window.parent.postMessage({ failedToLoad: true }, '*'); - throw new Error('Storage is not available'); - } + blocked = true; } + + if (!blocked) return; + + window.parent.postMessage({ failedToLoad: true }, '*'); + throw new Error('Storage is not available'); } diff --git a/lib/options/options.entry.js b/lib/options/options.entry.js index f035e08146..0c1496d185 100644 --- a/lib/options/options.entry.js +++ b/lib/options/options.entry.js @@ -1,24 +1,96 @@ /* @flow */ -import './handleBlocking'; +import '../environment/foreground/diagnosticsBootstrap'; +import { installRequestIdleCallback } from '../utils/requestIdleCallback'; import * as Context from '../environment/foreground/context'; +import { getURL } from '../environment'; +import { reportDiagnostic } from '../environment/utils/diagnostics'; import * as Core from '../core/init'; import { allowedModules } from '../core/modules'; +import { ensureStorageAvailable } from './handleBlocking'; import * as SettingsConsole from './settingsConsole'; -// The options page depends on the context object in order to generate correct links and perform requests against Reddit -Context.retrieveFromParent().then(async () => { +installRequestIdleCallback(); + +function renderFailure(error: Error) { + document.body.replaceChildren(); + + const container = document.createElement('main'); + const title = document.createElement('h1'); + title.textContent = 'RES settings failed to load'; + + const summary = document.createElement('p'); + summary.textContent = error.message || 'An unexpected error prevented the settings console from loading.'; + + const diagnostics = document.createElement('p'); + const diagnosticsLink = document.createElement('a'); + diagnosticsLink.href = getURL('debug.html'); + diagnosticsLink.textContent = 'Open Safari diagnostics'; + diagnostics.append('Inspect the Safari diagnostics page for the captured runtime error: ', diagnosticsLink); + + const stack = document.createElement('pre'); + stack.textContent = error.stack || ''; + + container.append(title, summary, diagnostics, stack); + document.body.append(container); +} + +async function logStage(stage: string, message: string) { + await reportDiagnostic({ + level: 'info', + message, + source: 'options', + stage, + }); +} + +async function bootstrap() { + await logStage('storage', 'Checking extension storage availability.'); + ensureStorageAvailable(); + + if (window !== window.parent) { + await logStage('context', 'Retrieving context from the parent page.'); + const contextReceived = await Context.retrieveFromParent(1500); + await logStage( + 'context', + contextReceived ? + 'Context retrieved.' : + 'Context request timed out. Continuing with standalone context.', + ); + } else { + await logStage('context', 'No parent context is required for the standalone options page.'); + } + allowedModules.push('nightMode', 'notifications'); + await logStage('init', 'Calling Core.init().'); Core.init(); - await Promise.all([Core.loadI18n, Core.loadOptions]); + await logStage('loadI18n', 'Waiting for translations to load.'); + await Core.loadI18n; + await logStage('loadI18n', 'Translations loaded.'); + + await logStage('loadOptions', 'Waiting for options to load.'); + await Core.loadOptions; + await logStage('loadOptions', 'Options loaded.'); + await logStage('settingsConsole', 'Starting settings console.'); SettingsConsole.start(); + await logStage('settingsConsole', 'Settings console started.'); // Signal to settingsNavigation that it seems to be going well window.parent.postMessage({ loadSuccess: true }, '*'); -}).catch(e => { - console.error(e); +} + +bootstrap().catch(async error => { + console.error(error); + await reportDiagnostic({ + level: 'error', + message: error.message || 'Options bootstrap failed.', + source: 'options', + stack: error.stack || '', + stage: 'startup', + }); + renderFailure(error); window.parent.postMessage({ failedToLoad: true }, '*'); }); diff --git a/lib/options/options.html b/lib/options/options.html index 7be5ffae90..9d2c10c67b 100644 --- a/lib/options/options.html +++ b/lib/options/options.html @@ -6,6 +6,13 @@ - + +
+

RES Settings Console

+

Loading settings.

+

If this page stays empty or never finishes loading, open Safari diagnostics.

+
+ + diff --git a/lib/options/settingsConsole.js b/lib/options/settingsConsole.js index 65c7783177..7cca000f65 100644 --- a/lib/options/settingsConsole.js +++ b/lib/options/settingsConsole.js @@ -20,6 +20,7 @@ import { watchForDescendants, } from '../utils'; import { context, i18n, Permissions } from '../environment'; +import { reportDiagnostic } from '../environment/utils/diagnostics'; import * as About from '../modules/about'; import { showNotification } from '../modules/notifications'; import * as SettingsNavigation from '../modules/settingsNavigation'; @@ -68,9 +69,25 @@ export function start() { watchForDescendants(document.body, 'a', e => { const a = downcast(e, HTMLAnchorElement); if (SettingsNavigation.isSettingsUrl(a.href)) return; - a.href = new URL(e.getAttribute('href'), context.origin).href; + + const href = e.getAttribute('href'); + if (!href) return; + + try { + a.href = new URL(href, context.origin).href; + } catch (error) { + reportDiagnostic({ + level: 'warn', + message: error.message || 'Failed to normalize settings link URL.', + source: 'options', + stack: error.stack || '', + stage: 'settingsConsole:link-normalization', + }); + return; + } + // Redirect the top frame if not opening new tab - if (!a.target.includes('_blank')) a.target += ' _parent'; + if (!(a.target || '').includes('_blank')) a.target = `${a.target || ''} _parent`.trim(); }); } diff --git a/lib/utils/__tests__/requestIdleCallback.js b/lib/utils/__tests__/requestIdleCallback.js new file mode 100644 index 0000000000..7c78096138 --- /dev/null +++ b/lib/utils/__tests__/requestIdleCallback.js @@ -0,0 +1,32 @@ +/* @flow */ + +import test from 'ava'; + +import { installRequestIdleCallback } from '../requestIdleCallback.js'; + +test('installRequestIdleCallback adds missing callbacks', t => { + let cleared; + const target = { + setTimeout(callback) { + callback(); + return 42; + }, + clearTimeout(handle) { + cleared = handle; + }, + }; + + installRequestIdleCallback(target); + + let remaining = -1; + const handle = target.requestIdleCallback(deadline => { + t.false(deadline.didTimeout); + remaining = deadline.timeRemaining(); + }); + + target.cancelIdleCallback(handle); + + t.is(handle, 42); + t.is(cleared, 42); + t.true(remaining >= 0); +}); diff --git a/lib/utils/requestIdleCallback.js b/lib/utils/requestIdleCallback.js new file mode 100644 index 0000000000..125aec6f7a --- /dev/null +++ b/lib/utils/requestIdleCallback.js @@ -0,0 +1,32 @@ +/* @flow */ +/* global globalThis */ + +type IdleDeadline = {| + didTimeout: boolean, + timeRemaining: () => number, +|}; + +function createIdleDeadline(startTime: number): IdleDeadline { + return { + didTimeout: false, + timeRemaining: () => Math.max(0, 50 - (Date.now() - startTime)), + }; +} + +export function installRequestIdleCallback(target: any = (globalThis: any)) { + if (typeof target.requestIdleCallback !== 'function') { + target.requestIdleCallback = (callback, options) => { + const startTime = Date.now(); + const timeout = options && typeof options.timeout === 'number' ? options.timeout : 1; + return target.setTimeout(() => callback(createIdleDeadline(startTime)), timeout); + }; + } + + if (typeof target.cancelIdleCallback !== 'function') { + target.cancelIdleCallback = handle => { + target.clearTimeout(handle); + }; + } + + return target.requestIdleCallback; +} diff --git a/package.json b/package.json index 254bf2d8b9..72b80a4c14 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "once": "node build.js --mode development", "prebuild": "rimraf dist", "build": "node build.js --mode production --zip", + "safari:convert": "node build/safariConvert.js", + "safari:validate": "yarn build --browsers safari && node build/safariConvert.js --validate-build", "autoi18n": "jscodeshift --parser flow --transform build/i18nTransformer.cjs", "postautoi18n": "eslint --fix lib/modules/*.js", "eslint": "eslint .", diff --git a/safari/manifest.json b/safari/manifest.json new file mode 100644 index 0000000000..7cd8a8feb2 --- /dev/null +++ b/safari/manifest.json @@ -0,0 +1,103 @@ +{ + "manifest_version": 3, + "name": "__name__", + "version": "__version__", + "description": "__description__", + "author": "__author__", + "homepage_url": "__homepage__", + "icons": { + "48": "icon48.png", + "128": "icon128.png" + }, + "action": { + "default_icon": { + "19": "css-on-small.png", + "38": "css-on.png" + } + }, + "options_ui": { + "page": "options.html" + }, + "background": { + "service_worker": "background.entry.js" + }, + "content_scripts": [ + { + "matches": [ + "https://*.reddit.com/*" + ], + "all_frames": true, + "exclude_matches": [ + "https://mod.reddit.com/*", + "https://ads.reddit.com/*", + "https://i.reddit.com/*", + "https://m.reddit.com/*", + "https://static.reddit.com/*", + "https://thumbs.reddit.com/*", + "https://blog.reddit.com/*", + "https://code.reddit.com/*", + "https://about.reddit.com/*", + "https://sh.reddit.com/*", + "https://*.reddit.com/talk/*", + "https://*.reddit.com/chat/*", + "https://*.reddit.com/*.compact", + "https://*.reddit.com/*.compact?*", + "https://*.reddit.com/*.mobile", + "https://*.reddit.com/*.mobile?*", + "https://*.reddit.com/*.json", + "https://*.reddit.com/*.json?*", + "https://*.reddit.com/*.json-html", + "https://*.reddit.com/*.json-html?*" + ], + "js": [ + "foreground.entry.js" + ], + "css": [ + "res.css" + ], + "run_at": "document_start" + } + ], + "content_security_policy": { + "extension_pages": "default-src 'self'; script-src 'self'; img-src 'self' data:; connect-src https:; font-src 'self' data:; frame-ancestors https://*.reddit.com; script-src-elem 'self'" + }, + "permissions": [ + "activeTab", + "tabs", + "storage", + "webRequest", + "scripting" + ], + "host_permissions": [ + "https://*.reddit.com/*", + "https://*.redd.it/*" + ], + "optional_host_permissions": [ + "https://publish.twitter.com/oembed", + "https://backend.deviantart.com/oembed", + "https://api.gyazo.com/api/oembed", + "https://api.tumblr.com/v2/blog/*/posts", + "https://xkcd.com/*/info.0.json", + "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/*", + "https://www.googleapis.com/drive/v3/*", + "https://www.flickr.com/services/oembed", + "https://redditenhancementsuite.com/oauth", + "https://accounts.google.com/signin/oauth", + "https://www.dropbox.com/oauth2/authorize", + "https://login.live.com/oauth20_authorize.srf", + "https://embed.bsky.app/oembed" + ], + "web_accessible_resources": [ + { + "resources": [ + "prompt.html", + "prompt.entry.js", + "options.css", + "options.html" + ], + "matches": [ + "https://*.reddit.com/*" + ] + } + ] +}