Rewrite options page with Svelte

This commit is contained in:
hensm
2022-06-02 20:16:01 +01:00
committed by Matt Hensman
parent c2f00a2412
commit 63f9af30ae
15 changed files with 1741 additions and 1094 deletions

View File

@@ -6,6 +6,8 @@ const path = require("path");
const esbuild = require("esbuild"); const esbuild = require("esbuild");
const minimist = require("minimist"); const minimist = require("minimist");
const sveltePlugin = require("esbuild-svelte");
const sveltePreprocess = require("svelte-preprocess");
const webExt = require("web-ext"); const webExt = require("web-ext");
const { copyFilesPlugin } = require("./lib/copyFilesPlugin.js"); const { copyFilesPlugin } = require("./lib/copyFilesPlugin.js");
@@ -86,7 +88,7 @@ const buildOpts = {
path.join(srcPath, "/cast/senders/mirroring.ts"), path.join(srcPath, "/cast/senders/mirroring.ts"),
// UI // UI
path.join(srcPath, "ui/popup/index.tsx"), path.join(srcPath, "ui/popup/index.tsx"),
path.join(srcPath, "ui/options/index.tsx") path.join(srcPath, "ui/options/index.ts")
], ],
define: { define: {
BRIDGE_NAME: `"${BRIDGE_NAME}"`, BRIDGE_NAME: `"${BRIDGE_NAME}"`,
@@ -95,12 +97,17 @@ const buildOpts = {
}, },
plugins: [ plugins: [
preactCompatPlugin, preactCompatPlugin,
// @ts-ignore
sveltePlugin({
// @ts-ignore
preprocess: sveltePreprocess()
}),
// Copy static files // Copy static files
copyFilesPlugin({ copyFilesPlugin({
src: srcPath, src: srcPath,
dest: outPath, dest: outPath,
excludePattern: /^(manifest\.json|.*\.(ts|tsx|js|jsx))$/ excludePattern: /^(manifest\.json|.*\.(ts|tsx|js|jsx|svelte))$/
}) })
] ]
}; };
@@ -117,7 +124,7 @@ if (argv.mode === "production") {
* @param {esbuild.BuildResult} result * @param {esbuild.BuildResult} result
*/ */
function onBuildResult(result) { function onBuildResult(result) {
if (result.errors.length) { if (result?.errors.length) {
console.error("Build error!"); console.error("Build error!");
return; return;
} }

View File

@@ -339,7 +339,7 @@
"description": "Add new whitelist item button title." "description": "Add new whitelist item button title."
}, },
"optionsSiteWhitelistUserAgent": { "optionsSiteWhitelistUserAgent": {
"message": "Enable UA", "message": "Disable UA",
"description": "Whitelist item user agent checkbox title." "description": "Whitelist item user agent checkbox title."
}, },
"optionsSiteWhitelistEditItem": { "optionsSiteWhitelistEditItem": {
@@ -350,15 +350,9 @@
"message": "Remove", "message": "Remove",
"description": "Remove whitelist item button title. Displayed on each item." "description": "Remove whitelist item button title. Displayed on each item."
}, },
"optionsSiteWhitelistInvalidMatchPattern": { "optionsSiteWhitelistInvalidDuplicatePattern": {
"message": "Invalid match pattern $matchPattern$", "message": "Match pattern already exists!",
"description": "Error displayed by input indicating an invalid match pattern.", "description": "Error displayed by input indicating a duplicate match pattern."
"placeholders": {
"matchPattern": {
"content": "$1",
"example": "http://example"
}
}
}, },
"optionsMirroringCategoryName": { "optionsMirroringCategoryName": {

1
ext/src/global.d.ts vendored
View File

@@ -5,6 +5,7 @@ declare const BRIDGE_NAME: string;
declare const MIRRORING_APP_ID: string; declare const MIRRORING_APP_ID: string;
declare type Nullable<T> = T | null; declare type Nullable<T> = T | null;
declare type Optional<T> = T | undefined;
declare type DistributiveOmit<T, K extends keyof any> = T extends any declare type DistributiveOmit<T, K extends keyof any> = T extends any
? Omit<T, K> ? Omit<T, K>

View File

@@ -0,0 +1,295 @@
<script lang="ts">
import semver from "semver";
import { onMount } from "svelte";
import bridge, { BridgeInfo, BridgeTimedOutError } from "../../lib/bridge";
import logger from "../../lib/logger";
import { Options } from "../../lib/options";
import { getNextEllipsis } from "../utils";
const _ = browser.i18n.getMessage;
export let opts: Options;
let bridgeInfo: Nullable<BridgeInfo> = null;
let isLoadingInfo = true;
let isLoadingInfoTimedOut = false;
// Status
let infoClass = "bridge__info";
let statusIcon: string;
let statusTitle: string;
let statusText: Nullable<string> = null;
onMount(async () => {
try {
bridgeInfo = await bridge.getInfo();
} catch (err) {
logger.error("Failed to fetch bridge/platform info.");
if (err instanceof BridgeTimedOutError) {
isLoadingInfoTimedOut = true;
}
}
isLoadingInfo = false;
infoClass += ` ${
!bridgeInfo
? isLoadingInfoTimedOut
? "bridge__info--timed-out"
: "bridge__info--not-found"
: "bridge__info--found"
}`;
if (!bridgeInfo) {
statusIcon = "assets/icons8-cancel-120.png";
statusTitle = _("optionsBridgeNotFoundStatusTitle");
statusText = _("optionsBridgeNotFoundStatusText");
} else if (isLoadingInfoTimedOut) {
statusIcon = "assets/icons8-warn-120.png";
statusTitle = _("optionsBridgeIssueStatusTitle");
} else {
if (bridgeInfo.isVersionCompatible) {
statusIcon = "assets/icons8-ok-120.png";
statusTitle = _("optionsBridgeFoundStatusTitle");
} else {
statusIcon = "assets/icons8-warn-120.png";
statusTitle = _("optionsBridgeIssueStatusTitle");
}
}
});
// Updates
let updateData: Nullable<GitHubRelease> = null;
let updateStatus: Nullable<string> = null;
let updateStatusTimeout: number;
let isCheckingUpdate = false;
let isUpdateAvailable = false;
let checkUpdateEllipsis = "";
interface GitHubRelease {
url: string;
tag_name: string;
html_url: string;
assets: Array<{
content_type: string;
html_url: string;
}>;
}
async function checkUpdate() {
isCheckingUpdate = true;
const checkUpdateTimeout = window.setInterval(() => {
checkUpdateEllipsis = getNextEllipsis(checkUpdateEllipsis);
}, 500);
let releases: GitHubRelease[];
try {
releases = await fetch(
"https://api.github.com/repos/hensm/fx_cast/releases"
).then(res => res.json());
} catch (err) {
isCheckingUpdate = false;
updateStatus = _("optionsBridgeUpdateStatusError");
return;
} finally {
window.clearTimeout(checkUpdateTimeout);
}
// Ensure valid response
if (!Array.isArray(releases)) {
throw logger.error("Check update response is not array.", releases);
}
// First non-extension-only release
const latestBridgeRelease = releases.find(release =>
release.assets.find(
asset => asset.content_type !== "application/x-xpinstall"
)
);
if (!latestBridgeRelease) {
throw logger.error(
"Check update response does not contain release info."
);
}
/**
* Update available if no bridge found or bridge version lower
* than fetched release version.
*/
isUpdateAvailable =
!bridgeInfo ||
semver.lt(bridgeInfo.version, latestBridgeRelease.tag_name);
if (isUpdateAvailable) {
updateData = latestBridgeRelease;
} else {
updateStatus = _("optionsBridgeUpdateStatusNoUpdates");
}
isCheckingUpdate = false;
if (updateStatusTimeout) {
window.clearTimeout(updateStatusTimeout);
}
updateStatusTimeout = window.setTimeout(() => {
updateStatus = null;
}, 1500);
}
function getUpdate() {
// Open downloads page
if (updateData?.html_url) {
browser.tabs.create({ url: updateData.html_url });
}
}
const [backupMessageStart, backupMessageEnd] = _(
"optionsBridgeBackupEnabled",
"\0"
).split("\0");
</script>
<div class="bridge">
{#if isLoadingInfo}
<div class="bridge__loading">
{_("optionsBridgeLoading")}
<progress />
</div>
{:else}
<div class={infoClass}>
<div class="bridge__status">
<img
class="bridge__status-icon"
width="60"
height="60"
src={statusIcon}
alt="icon, bridge status"
/>
<h2 class="bridge__status-title">{statusTitle}</h2>
{#if statusText}
<p class="bridge__status-text">{statusText}</p>
{/if}
</div>
{#if bridgeInfo}
<table class="bridge__stats">
<tr>
<th>{_("optionsBridgeStatsName")}</th>
<td>{bridgeInfo.name}</td>
</tr>
<tr>
<th>{_("optionsBridgeStatsVersion")}</th>
<td>{bridgeInfo.version}</td>
</tr>
<tr>
<th>{_("optionsBridgeStatsExpectedVersion")}</th>
<td>{bridgeInfo.expectedVersion}</td>
</tr>
<tr>
<th>{_("optionsBridgeStatsCompatibility")}</th>
<td>
{bridgeInfo.isVersionCompatible
? bridgeInfo.isVersionExact
? _("optionsBridgeCompatible")
: _("optionsBridgeLikelyCompatible")
: _("optionsBridgeIncompatible")}
</td>
</tr>
<tr>
<th>{_("optionsBridgeStatsRecommendedAction")}</th>
<td>
{bridgeInfo.isVersionCompatible
? _("optionsBridgeNoAction")
: bridgeInfo.isVersionOlder
? _("optionsBridgeOlderAction")
: bridgeInfo.isVersionNewer
? _("optionsBridgeNewerAction")
: _("optionsBridgeNoAction")}
</td>
</tr>
</table>
{/if}
</div>
<div class="bridge__options">
<div class="option option--inline">
<div class="option__control">
<input
name="bridgeBackupEnabled"
id="bridgeBackupEnabled"
type="checkbox"
bind:checked={opts.bridgeBackupEnabled}
/>
</div>
<label class="option__label" for="bridgeBackupEnabled">
{backupMessageStart}
<input
class="bridge__backup-host"
name="bridgeBackupHost"
type="text"
required
bind:value={opts.bridgeBackupHost}
/>
:
<input
class="bridge__backup-port"
name="bridgeBackupPort"
type="number"
required
min="1025"
max="65535"
bind:value={opts.bridgeBackupPort}
/>
{backupMessageEnd}
</label>
<div class="option__description">
{_("optionsBridgeBackupEnabledDescription")}
</div>
</div>
</div>
<div class="bridge__update-info">
{#if isUpdateAvailable}
<div class="bridge__update">
<p class="bridge__update-label">
{_("optionsBridgeUpdateAvailable")}
</p>
<div class="bridge__update-options">
<button
class="bridge__update-start"
type="button"
on:click={getUpdate}
>
{_("optionsBridgeUpdate")}
</button>
</div>
</div>
{:else}
<button
class="bridge__update-check"
type="button"
disabled={isCheckingUpdate}
on:click={checkUpdate}
>
{#if isCheckingUpdate}
{_("optionsBridgeUpdateChecking", checkUpdateEllipsis)}
{:else}
{_("optionsBridgeUpdateCheck")}
{/if}
</button>
{/if}
{#if updateStatus && !isUpdateAvailable}
<div class="bridge--update-status">
{updateStatus}
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -1,360 +0,0 @@
/* eslint-disable max-len */
"use strict";
import React, { Component } from "react";
import semver from "semver";
import { Options } from "../../lib/options";
import { BridgeInfo } from "../../lib/bridge";
import { getNextEllipsis } from "../../lib/utils";
import logger from "../../lib/logger";
const _ = browser.i18n.getMessage;
interface Release {
url: string;
tag_name: string;
html_url: string;
assets: Array<{
content_type: string;
html_url: string;
}>;
}
interface BridgeStatsProps {
info: BridgeInfo;
}
const BridgeStats = (props: BridgeStatsProps) => (
<table className="bridge__stats">
<tr>
<th>{_("optionsBridgeStatsName")}</th>
<td>{props.info.name}</td>
</tr>
<tr>
<th>{_("optionsBridgeStatsVersion")}</th>
<td>{props.info.version}</td>
</tr>
<tr>
<th>{_("optionsBridgeStatsExpectedVersion")}</th>
<td>{props.info.expectedVersion}</td>
</tr>
<tr>
<th>{_("optionsBridgeStatsCompatibility")}</th>
<td>
{props.info.isVersionCompatible
? props.info.isVersionExact
? _("optionsBridgeCompatible")
: _("optionsBridgeLikelyCompatible")
: _("optionsBridgeIncompatible")}
</td>
</tr>
<tr>
<th>{_("optionsBridgeStatsRecommendedAction")}</th>
<td>
{props.info.isVersionCompatible
? _("optionsBridgeNoAction")
: props.info.isVersionOlder
? _("optionsBridgeOlderAction")
: props.info.isVersionNewer
? _("optionsBridgeNewerAction")
: _("optionsBridgeNoAction")}
</td>
</tr>
</table>
);
interface BridgeProps {
info?: BridgeInfo;
loading: boolean;
loadingTimedOut: boolean;
options?: Options;
onChange: (ev: React.ChangeEvent<HTMLInputElement>) => void;
}
interface BridgeState {
isCheckingUpdates: boolean;
isUpdateAvailable: boolean;
wasErrorCheckingUpdates: boolean;
checkUpdatesEllipsis: string;
updateStatus?: string;
}
export default class Bridge extends Component<BridgeProps, BridgeState> {
private updateData?: Release;
private updateStatusTimeout?: number;
constructor(props: BridgeProps) {
super(props);
this.state = {
isCheckingUpdates: false,
isUpdateAvailable: false,
wasErrorCheckingUpdates: false,
checkUpdatesEllipsis: "..."
};
this.onCheckUpdates = this.onCheckUpdates.bind(this);
this.onCheckUpdatesResponse = this.onCheckUpdatesResponse.bind(this);
this.onCheckUpdatesError = this.onCheckUpdatesError.bind(this);
this.onUpdate = this.onUpdate.bind(this);
}
public render() {
const [backupMessageStart, backupMessageEnd] = _(
"optionsBridgeBackupEnabled",
"\0"
).split("\0");
return (
<div className="bridge">
{this.props.loading ? (
<div className="bridge__loading">
{_("optionsBridgeLoading")}
<progress></progress>
</div>
) : (
this.renderStatus()
)}
{!this.props.loading && this.props.options && (
<div className="bridge__options">
<label className="option option--inline">
<div className="option__control">
<input
name="bridgeBackupEnabled"
type="checkbox"
checked={
this.props.options.bridgeBackupEnabled
}
onChange={this.props.onChange}
/>
</div>
<div className="option__label">
{backupMessageStart}
<input
className="bridge__backup-host"
name="bridgeBackupHost"
type="text"
required
value={this.props.options.bridgeBackupHost}
onChange={this.props.onChange}
/>
:
<input
className="bridge__backup-port"
name="bridgeBackupPort"
type="number"
required
min="1025"
max="65535"
value={this.props.options.bridgeBackupPort}
onChange={this.props.onChange}
/>
{backupMessageEnd}
</div>
<div className="option__description">
{_("optionsBridgeBackupEnabledDescription")}
</div>
</label>
</div>
)}
{!this.props.loading && (
<div className="bridge__update-info">
{this.state.isUpdateAvailable ? (
<div className="bridge__update">
<p className="bridge__update-label">
{_("optionsBridgeUpdateAvailable")}
</p>
<div className="bridge__update-options">
<button
className="bridge__update-start"
type="button"
onClick={this.onUpdate}
>
{_("optionsBridgeUpdate")}
</button>
</div>
</div>
) : (
<button
className="bridge__update-check"
type="button"
disabled={this.state.isCheckingUpdates}
onClick={this.onCheckUpdates}
>
{this.state.isCheckingUpdates
? _(
"optionsBridgeUpdateChecking",
getNextEllipsis(
this.state.checkUpdatesEllipsis
)
)
: _("optionsBridgeUpdateCheck")}
</button>
)}
<div className="bridge--update-status">
{this.state.updateStatus &&
!this.state.isUpdateAvailable &&
this.state.updateStatus}
</div>
</div>
)}
</div>
);
}
private renderStatus() {
const infoClasses = `bridge__info ${
!this.props.info
? this.props.loadingTimedOut
? "bridge__info--timed-out"
: "bridge__info--not-found"
: "bridge__info--found"
}`;
let statusIcon: string;
let statusTitle: string;
let statusText: string | null = null;
if (this.props.loadingTimedOut) {
statusIcon = "assets/icons8-warn-120.png";
statusTitle = _("optionsBridgeIssueStatusTitle");
} else if (!this.props.info) {
statusIcon = "assets/icons8-cancel-120.png";
statusTitle = _("optionsBridgeNotFoundStatusTitle");
statusText = _("optionsBridgeNotFoundStatusText");
} else {
if (this.props.info.isVersionCompatible) {
statusIcon = "assets/icons8-ok-120.png";
statusTitle = _("optionsBridgeFoundStatusTitle");
} else {
statusIcon = "assets/icons8-warn-120.png";
statusTitle = _("optionsBridgeIssueStatusTitle");
}
}
return (
<div className={infoClasses}>
<div className="bridge__status">
<img
className="bridge__status-icon"
width="60"
height="60"
src={statusIcon}
/>
<h2 className="bridge__status-title">{statusTitle}</h2>
{statusText && (
<p className="bridge__status-text">{statusText}</p>
)}
</div>
{this.props.info && <BridgeStats info={this.props.info} />}
</div>
);
}
private onCheckUpdates() {
this.setState({
isCheckingUpdates: true
});
const timeout = window.setInterval(() => {
this.setState(state => ({
checkUpdatesEllipsis: getNextEllipsis(
state.checkUpdatesEllipsis
)
}));
}, 500);
fetch("https://api.github.com/repos/hensm/fx_cast/releases")
.then(res => {
window.clearTimeout(timeout);
return res.json();
})
.then(this.onCheckUpdatesResponse)
.catch(this.onCheckUpdatesError);
}
private async onCheckUpdatesResponse(res: Release[]) {
if (!Array.isArray(res)) {
throw logger.error("Check update response is not array.", res);
}
let latestBridgeRelease;
for (const release of res) {
if (
release.assets.find(
asset => asset.content_type !== "application/x-xpinstall"
)
) {
latestBridgeRelease = release;
break;
}
}
if (!latestBridgeRelease) {
throw logger.error(
"Check update response does not contain release info."
);
}
/**
* Update available if no bridge found or bridge version lower
* than fetched release version.
*/
const isUpdateAvailable =
!this.props.info ||
semver.lt(this.props.info.version, latestBridgeRelease.tag_name);
if (isUpdateAvailable) {
this.updateData = latestBridgeRelease;
} else {
this.setState({
updateStatus: _("optionsBridgeUpdateStatusNoUpdates")
});
}
this.setState({
isCheckingUpdates: false,
isUpdateAvailable
});
this.showUpdateStatus();
}
private onCheckUpdatesError() {
this.setState({
isCheckingUpdates: false,
wasErrorCheckingUpdates: true,
updateStatus: _("optionsBridgeUpdateStatusError")
});
this.showUpdateStatus();
}
private showUpdateStatus() {
if (this.updateStatusTimeout) {
window.clearTimeout(this.updateStatusTimeout);
}
this.updateStatusTimeout = window.setTimeout(() => {
this.setState({
updateStatus: undefined
});
}, 1500);
}
private async onUpdate() {
// Open downloads page
if (this.updateData?.html_url) {
browser.tabs.create({
url: this.updateData.html_url
});
}
}
}

View File

@@ -0,0 +1,305 @@
<script lang="ts">
import { afterUpdate, beforeUpdate, onMount } from "svelte";
import Bridge from "./Bridge.svelte";
import Whitelist from "./Whitelist.svelte";
void Bridge, Whitelist;
import logger from "../../lib/logger";
import options, { Options } from "../../lib/options";
import defaultOptions from "../../defaultOptions";
const _ = browser.i18n.getMessage;
let formElement: HTMLFormElement;
let isFormValid = true;
let showSavedIndicator = false;
let opts: Options | undefined;
onMount(async () => {
opts = await options.getAll();
options.addEventListener("changed", async () => {
opts = await options.getAll();
});
});
afterUpdate(() => {
isFormValid = formElement?.checkValidity();
});
/** Saves options and show indicator. */
async function onFormSubmit() {
formElement.reportValidity();
try {
if (!opts) return;
// Remove unnecessary prop
for (const item of opts.siteWhitelist) {
if (item.isUserAgentDisabled === false) {
delete item.isUserAgentDisabled;
}
}
await options.setAll(opts);
// 1s long saved indicator
showSavedIndicator = true;
setTimeout(() => {
showSavedIndicator = false;
}, 1000);
} catch (err) {
logger.error("Failed to save options!");
}
}
function onFormInput() {
isFormValid = formElement.checkValidity();
}
function resetForm() {
opts = JSON.parse(JSON.stringify(defaultOptions));
}
</script>
{#if opts}
<form
id="form"
bind:this={formElement}
on:input={onFormInput}
on:submit|preventDefault={onFormSubmit}
>
<Bridge bind:opts />
<fieldset class="category">
<legend class="category__name">
<h2>{_("optionsMediaCategoryName")}</h2>
</legend>
<p class="category__description">
{_("optionsMediaCategoryDescription")}
</p>
<div class="option option--inline">
<div class="option__control">
<input
name="mediaEnabled"
id="mediaEnabled"
type="checkbox"
bind:checked={opts.mediaEnabled}
/>
</div>
<label class="option__label" for="mediaEnabled">
{_("optionsMediaEnabled")}
</label>
</div>
<div class="option option--inline">
<div class="option__control">
<input
name="mediaSyncElement"
id="mediaSyncElement"
type="checkbox"
bind:checked={opts.mediaSyncElement}
/>
</div>
<label class="option__label" for="mediaSyncElement">
{_("optionsMediaSyncElement")}
</label>
<div class="option__description">
{_("optionsMediaSyncElementDescription")}
</div>
</div>
<div class="option option--inline">
<div class="option__control">
<input
name="mediaStopOnUnload"
id="mediaStopOnUnload"
type="checkbox"
bind:checked={opts.mediaStopOnUnload}
/>
</div>
<label class="option__label" for="mediaStopOnUnload">
{_("optionsMediaStopOnUnload")}
</label>
</div>
<hr />
<div class="option option--inline">
<div class="option__control">
<input
name="localMediaEnabled"
id="localMediaEnabled"
type="checkbox"
bind:checked={opts.localMediaEnabled}
/>
</div>
<label class="option__label" for="localMediaEnabled">
{_("optionsLocalMediaEnabled")}
</label>
<div class="option__description">
{_("optionsLocalMediaCategoryDescription")}
</div>
</div>
<div class="option">
<label class="option__label" for="localMediaServerPort">
{_("optionsLocalMediaServerPort")}
</label>
<div class="option__control">
<input
name="localMediaServerPort"
id="localMediaServerPort"
type="number"
required
min="1025"
max="65535"
bind:value={opts.localMediaServerPort}
/>
</div>
</div>
</fieldset>
<fieldset class="category">
<legend class="category__name">
<h2>{_("optionsMirroringCategoryName")}</h2>
</legend>
<p class="category__description">
{_("optionsMirroringCategoryDescription")}
</p>
<div class="option option--inline">
<div class="option__control">
<input
name="mirroringEnabled"
id="mirroringEnabled"
type="checkbox"
bind:checked={opts.mirroringEnabled}
/>
</div>
<label class="option__label" for="mirroringEnabled">
{_("optionsMirroringEnabled")}
</label>
</div>
<div class="option">
<label class="option__label" for="mirroringAppId">
{_("optionsMirroringAppId")}
</label>
<div class="option__control">
<input
name="mirroringAppId"
id="mirroringAppId"
type="text"
required
bind:value={opts.mirroringAppId}
/>
<div class="option__description">
{_("optionsMirroringAppIdDescription")}
</div>
</div>
</div>
</fieldset>
<fieldset class="category">
<legend class="category__name">
<h2>{_("optionsReceiverSelectorCategoryName")}</h2>
</legend>
<p class="category__description">
{_("optionsReceiverSelectorCategoryDescription")}
</p>
<div class="option option--inline">
<div class="option__control">
<input
name="receiverSelectorWaitForConnection"
id="receiverSelectorWaitForConnection"
type="checkbox"
bind:checked={opts.receiverSelectorWaitForConnection}
/>
</div>
<label
class="option__label"
for="receiverSelectorWaitForConnection"
>
{_("optionsReceiverSelectorWaitForConnection")}
</label>
<div class="option__description">
{_("optionsReceiverSelectorWaitForConnectionDescription")}
</div>
</div>
<div class="option option--inline">
<div class="option__control">
<input
name="receiverSelectorCloseIfFocusLost"
id="receiverSelectorCloseIfFocusLost"
type="checkbox"
bind:checked={opts.receiverSelectorCloseIfFocusLost}
/>
</div>
<label
class="option__label"
for="receiverSelectorCloseIfFocusLost"
>
{_("optionsReceiverSelectorCloseIfFocusLost")}
</label>
</div>
</fieldset>
<fieldset class="category">
<legend class="category__name">
<h2>{_("optionsSiteWhitelistCategoryName")}</h2>
</legend>
<p class="category__description">
{_("optionsSiteWhitelistCategoryDescription")}
</p>
<div class="option option--inline">
<div class="option__control">
<input
name="siteWhitelistEnabled"
id="siteWhitelistEnabled"
type="checkbox"
bind:checked={opts.siteWhitelistEnabled}
/>
</div>
<label class="option__label" for="siteWhitelistEnabled">
{_("optionsSiteWhitelistEnabled")}
<span class="option__recommended">
{_("optionsOptionRecommended")}
</span>
</label>
<div class="option__description">
{_("optionsSiteWhitelistEnabledDescription")}
</div>
</div>
<div class="option">
<div class="option__label">
{_("optionsSiteWhitelistContent")}
</div>
<div class="option__control">
<Whitelist bind:items={opts.siteWhitelist} />
</div>
</div>
</fieldset>
<div id="buttons">
{#if showSavedIndicator}
<div id="status-line">
{_("optionsSaved")}
</div>
{/if}
<button on:click={resetForm} type="button">
{_("optionsReset")}
</button>
<button type="submit" default disabled={!isFormValid}>
{_("optionsSave")}
</button>
</div>
</form>
{/if}

View File

@@ -0,0 +1,145 @@
<script lang="ts">
import { tick } from "svelte";
import { WhitelistItemData } from "../../background/whitelist";
import { REMOTE_MATCH_PATTERN_REGEX } from "../../lib/matchPattern";
void REMOTE_MATCH_PATTERN_REGEX;
const _ = browser.i18n.getMessage;
/** Whitelist items to display. */
export let items: WhitelistItemData[];
let isEditing = false;
let editingIndex: number;
let editingInput: HTMLInputElement;
let editingValue: string;
$: isEditingValid =
isEditing && REMOTE_MATCH_PATTERN_REGEX.test(editingValue);
async function beginEditing(index: number) {
if (isEditing) return;
editingIndex = index;
editingValue = items[index].pattern;
isEditing = true;
await tick();
isEditingValid = editingInput.validity.valid;
editingInput.focus();
editingInput.select();
}
function finishEditing() {
if (!isEditing || !editingInput.validity.valid) return;
isEditing = false;
items[editingIndex].pattern = editingValue;
}
function checkEditValidity() {
editingInput.setCustomValidity(
// Has duplicate pattern
items.some(
(item, index) =>
index !== editingIndex && item.pattern === editingValue
)
? _("optionsSiteWhitelistInvalidDuplicatePattern")
: ""
);
isEditingValid = editingInput.checkValidity();
}
function addItem() {
if (isEditing) return;
items = [...items, { pattern: "" }];
beginEditing(items.length - 1);
}
function removeItem(index: number) {
if (isEditing) {
if (index !== editingIndex) return;
isEditing = false;
}
items.splice(index, 1);
items = items;
}
</script>
<div class="whitelist">
<ul class="whitelist__items">
{#each items as item, i}
{@const isEditingItem = isEditing && editingIndex === i}
<li
class="whitelist__item"
class:whitelist__item--selected={isEditingItem}
on:dblclick={() => beginEditing(i)}
>
<div class="whitelist__title">
{#if isEditingItem}
<input
type="text"
class="whitelist__input-pattern"
pattern={REMOTE_MATCH_PATTERN_REGEX.source}
required
bind:this={editingInput}
bind:value={editingValue}
on:input={checkEditValidity}
on:blur={finishEditing}
on:keypress={ev =>
ev.key === "Enter" && finishEditing()}
/>
{:else}
{item.pattern}
{/if}
</div>
{#if !isEditingItem}
<label class="whitelist__user-agent">
<input
type="checkbox"
bind:checked={item.isUserAgentDisabled}
/>
{_("optionsSiteWhitelistUserAgent")}
</label>
<button
type="button"
class="whitelist__edit-button ghost"
title={_("optionsSiteWhitelistEditItem")}
disabled={isEditing && !isEditingValid}
on:click={() => beginEditing(i)}
>
<img src="assets/photon_edit.svg" alt="icon, edit" />
</button>
{/if}
<button
type="button"
class="whitelist__remove-button ghost"
title={_("optionsSiteWhitelistRemoveItem")}
disabled={isEditing && !isEditingItem && !isEditingValid}
on:click={() => removeItem(i)}
>
<img src="assets/photon_delete.svg" alt="icon, remove" />
</button>
</li>
{/each}
</ul>
<hr />
<div class="whitelist__view-actions">
<button
class="whitelist__add-button ghost"
title={_("optionsSiteWhitelistAddItem")}
on:click={addItem}
type="button"
>
<img src="assets/photon_new.svg" alt="icon, add" />
</button>
</div>
</div>

View File

@@ -1,225 +0,0 @@
/* eslint-disable max-len */
"use strict";
import React, { Component } from "react";
import type { WhitelistItemData } from "../../background/whitelist";
import { REMOTE_MATCH_PATTERN_REGEX } from "../../lib/matchPattern";
const _ = browser.i18n.getMessage;
interface WhitelistProps {
items: WhitelistItemData[];
onChange: (items: WhitelistItemData[]) => void;
}
interface WhitelistState {
addingNewItem: boolean;
}
/** Editable list component for site whitelist. */
export default class Whitelist extends Component<
WhitelistProps,
WhitelistState
> {
state: WhitelistState = {
addingNewItem: false
};
render() {
return (
<div className="whitelist">
<ul className="whitelist__items">
{this.props.items.map((item, i) => (
<WhitelistItem
value={item}
onEdit={(oldValue, newValue) => {
// Replace item
this.props.onChange(
this.props.items.map(item =>
item.pattern === oldValue?.pattern
? newValue
: item
)
);
}}
onRemove={value => {
// Remove item
this.props.onChange(
this.props.items.filter(
item => item.pattern !== value?.pattern
)
);
}}
key={i}
/>
))}
{this.state.addingNewItem && (
<WhitelistItem
isEditing={true}
onEdit={(__, newValue) => {
// Add new item
this.setState({ addingNewItem: false }, () => {
this.props.onChange([
...this.props.items,
newValue
]);
});
}}
onRemove={() => {
// Cancel adding new item
this.setState({ addingNewItem: false });
}}
/>
)}
</ul>
<hr />
<div className="whitelist__view-actions">
<button
className="whitelist__add-button ghost"
title={_("optionsSiteWhitelistAddItem")}
onClick={() => this.setState({ addingNewItem: true })}
type="button"
>
<img src="assets/photon_new.svg" alt="icon, add" />
</button>
</div>
</div>
);
}
}
interface WhitelistItemProps {
value?: WhitelistItemData;
/** Initial editing state */
isEditing?: boolean;
onEdit: (
oldValue: WhitelistItemData | undefined,
newValue: WhitelistItemData
) => void;
onRemove: (value?: WhitelistItemData) => void;
}
interface WhitelistItemState {
isEditing: boolean;
editValue?: WhitelistItemData;
}
/** Editable item component for whitelist. */
class WhitelistItem extends Component<WhitelistItemProps, WhitelistItemState> {
private inputElement: HTMLInputElement | null = null;
constructor(props: WhitelistItemProps) {
super(props);
this.state = { isEditing: props.isEditing ?? false };
this.beginEditing = this.beginEditing.bind(this);
this.finishEditing = this.finishEditing.bind(this);
}
/** Sets editing state and focuses input field. */
private beginEditing() {
if (this.state.isEditing) return;
this.setState(
{
isEditing: true,
editValue: this.props.value
},
() => {
this.inputElement?.focus();
this.inputElement?.select();
}
);
}
/** Checks input validity and sends edit update. */
private finishEditing() {
if (!this.state.isEditing || !this.state.editValue) return;
if (this.inputElement?.validity.valid) {
this.props.onEdit(this.props.value, this.state.editValue);
this.setState({
isEditing: false,
editValue: undefined
});
}
}
render() {
const selectedClassName = this.state.isEditing
? "whitelist__item--selected"
: "";
return (
<li className={`whitelist__item ${selectedClassName}`}>
<div
className="whitelist__title"
onDoubleClick={this.beginEditing}
>
{this.state.isEditing ? (
<input
ref={el => (this.inputElement = el)}
type="text"
className="whitelist__input-pattern"
value={this.state.editValue?.pattern}
pattern={REMOTE_MATCH_PATTERN_REGEX.source}
onChange={ev => {
this.setState(prevState => ({
editValue: {
...prevState.editValue,
pattern: ev.target.value
}
}));
}}
onBlur={this.finishEditing}
onKeyPress={ev => {
if (ev.key === "Enter") {
this.finishEditing();
}
}}
/>
) : (
this.props.value?.pattern
)}
</div>
<label className="whitelist__user-agent">
<input
type="checkbox"
checked={!this.props.value?.isUserAgentDisabled}
onChange={ev => {
if (!this.props.value) return;
this.props.onEdit(this.props.value, {
...this.props.value,
isUserAgentDisabled: !ev.target.checked
});
}}
/>
{_("optionsSiteWhitelistUserAgent")}
</label>
<button
className="whitelist__edit-button ghost"
title={_("optionsSiteWhitelistEditItem")}
onClick={this.beginEditing}
type="button"
>
<img src="assets/photon_edit.svg" alt="icon, edit" />
</button>
<button
className="whitelist__remove-button ghost"
title={_("optionsSiteWhitelistRemoveItem")}
onClick={() => {
this.props.onRemove(this.props.value);
}}
type="button"
>
<img src="assets/photon_delete.svg" alt="icon, remove" />
</button>
</li>
);
}
}

View File

@@ -0,0 +1,25 @@
"use strict";
import Options from "./Options.svelte";
// macOS styles
browser.runtime.getPlatformInfo().then(platformInfo => {
const link = document.createElement("link");
link.rel = "stylesheet";
switch (platformInfo.os) {
case "mac": {
link.href = "styles/mac.css";
break;
}
}
if (link.href) {
document.head.appendChild(link);
}
});
const target = document.getElementById("root");
if (target) {
new Options({ target });
}

View File

@@ -1,462 +0,0 @@
/* eslint-disable max-len */
"use strict";
import React, { Component } from "react";
import ReactDOM from "react-dom";
import defaultOptions from "../../defaultOptions";
import type { WhitelistItemData } from "../../background/whitelist";
import Bridge from "./Bridge";
import Whitelist from "./Whitelist";
import bridge, { BridgeInfo, BridgeTimedOutError } from "../../lib/bridge";
import logger from "../../lib/logger";
import options, { Options } from "../../lib/options";
const _ = browser.i18n.getMessage;
// macOS styles
browser.runtime.getPlatformInfo().then(platformInfo => {
const link = document.createElement("link");
link.rel = "stylesheet";
switch (platformInfo.os) {
case "mac": {
link.href = "styles/mac.css";
break;
}
}
if (link.href) {
document.head.appendChild(link);
}
});
function getInputValue(input: HTMLInputElement) {
switch (input.type) {
case "checkbox":
return input.checked;
case "number":
return parseFloat(input.value);
default:
return input.value;
}
}
interface OptionsAppProps {}
interface OptionsAppState {
hasLoaded: boolean;
bridgeLoading: boolean;
bridgeLoadingTimedOut: boolean;
isFormValid: boolean;
hasSaved: boolean;
options?: Options;
bridgeInfo?: BridgeInfo;
platform?: string;
}
class OptionsApp extends Component<OptionsAppProps, OptionsAppState> {
private form: HTMLFormElement | null = null;
state: OptionsAppState = {
hasLoaded: false,
bridgeLoading: true,
bridgeLoadingTimedOut: false,
isFormValid: true,
hasSaved: false
};
constructor(props: OptionsAppProps) {
super(props);
this.handleReset = this.handleReset.bind(this);
this.handleFormSubmit = this.handleFormSubmit.bind(this);
this.handleFormChange = this.handleFormChange.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
this.handleWhitelistChange = this.handleWhitelistChange.bind(this);
}
public async componentDidMount() {
this.setState({
hasLoaded: true,
options: await options.getAll(),
platform: (await browser.runtime.getPlatformInfo()).os
});
// Update options data if changed whilst page is open
options.addEventListener("changed", async () => {
this.setState({
options: await options.getAll()
});
});
try {
const bridgeInfo = await bridge.getInfo();
this.setState({
bridgeInfo,
bridgeLoading: false
});
} catch (err) {
logger.error("Failed to fetch bridge/platform info.");
if (err instanceof BridgeTimedOutError) {
this.setState({
bridgeLoading: false,
bridgeLoadingTimedOut: true
});
} else {
this.setState({
bridgeLoading: false
});
}
}
}
public render() {
if (!this.state.hasLoaded) {
return;
}
return (
<div>
<form
id="form"
ref={form => {
this.form = form;
}}
onSubmit={this.handleFormSubmit}
onChange={this.handleFormChange}
>
<Bridge
info={this.state.bridgeInfo}
loading={this.state.bridgeLoading}
loadingTimedOut={this.state.bridgeLoadingTimedOut}
options={this.state.options}
onChange={this.handleInputChange}
/>
<fieldset className="category">
<legend className="category__name">
<h2>{_("optionsMediaCategoryName")}</h2>
</legend>
<p className="category__description">
{_("optionsMediaCategoryDescription")}
</p>
<label className="option option--inline">
<div className="option__control">
<input
name="mediaEnabled"
type="checkbox"
checked={this.state.options?.mediaEnabled}
onChange={this.handleInputChange}
/>
</div>
<div className="option__label">
{_("optionsMediaEnabled")}
</div>
</label>
<label className="option option--inline">
<div className="option__control">
<input
name="mediaSyncElement"
type="checkbox"
checked={
this.state.options?.mediaSyncElement
}
onChange={this.handleInputChange}
/>
</div>
<div className="option__label">
{_("optionsMediaSyncElement")}
</div>
<div className="option__description">
{_("optionsMediaSyncElementDescription")}
</div>
</label>
<label className="option option--inline">
<div className="option__control">
<input
name="mediaStopOnUnload"
type="checkbox"
checked={
this.state.options?.mediaStopOnUnload
}
onChange={this.handleInputChange}
/>
</div>
<div className="option__label">
{_("optionsMediaStopOnUnload")}
</div>
</label>
<hr />
<label className="option option--inline">
<div className="option__control">
<input
name="localMediaEnabled"
type="checkbox"
checked={
this.state.options?.localMediaEnabled
}
onChange={this.handleInputChange}
/>
</div>
<div className="option__label">
{_("optionsLocalMediaEnabled")}
</div>
<div className="option__description">
{_("optionsLocalMediaCategoryDescription")}
</div>
</label>
<label className="option">
<div className="option__label">
{_("optionsLocalMediaServerPort")}
</div>
<div className="option__control">
<input
name="localMediaServerPort"
type="number"
required
min="1025"
max="65535"
value={
this.state.options?.localMediaServerPort
}
onChange={this.handleInputChange}
/>
</div>
</label>
</fieldset>
<fieldset className="category">
<legend className="category__name">
<h2>{_("optionsMirroringCategoryName")}</h2>
</legend>
<p className="category__description">
{_("optionsMirroringCategoryDescription")}
</p>
<label className="option option--inline">
<div className="option__control">
<input
name="mirroringEnabled"
type="checkbox"
checked={
this.state.options?.mirroringEnabled
}
onChange={this.handleInputChange}
/>
</div>
<div className="option__label">
{_("optionsMirroringEnabled")}
</div>
</label>
<label className="option">
<div className="option__label">
{_("optionsMirroringAppId")}
</div>
<div className="option__control">
<input
name="mirroringAppId"
type="text"
required
value={this.state.options?.mirroringAppId}
onChange={this.handleInputChange}
/>
<div className="option__description">
{_("optionsMirroringAppIdDescription")}
</div>
</div>
</label>
</fieldset>
<fieldset className="category">
<legend className="category__name">
<h2>{_("optionsReceiverSelectorCategoryName")}</h2>
</legend>
<p className="category__description">
{_("optionsReceiverSelectorCategoryDescription")}
</p>
<label className="option option--inline">
<div className="option__control">
<input
name="receiverSelectorWaitForConnection"
type="checkbox"
checked={
this.state.options
?.receiverSelectorWaitForConnection
}
onChange={this.handleInputChange}
/>
</div>
<div className="option__label">
{_("optionsReceiverSelectorWaitForConnection")}
</div>
<div className="option__description">
{_(
"optionsReceiverSelectorWaitForConnectionDescription"
)}
</div>
</label>
<label className="option option--inline">
<div className="option__control">
<input
name="receiverSelectorCloseIfFocusLost"
type="checkbox"
checked={
this.state.options
?.receiverSelectorCloseIfFocusLost
}
onChange={this.handleInputChange}
/>
</div>
<div className="option__label">
{_("optionsReceiverSelectorCloseIfFocusLost")}
</div>
</label>
</fieldset>
<fieldset className="category">
<legend className="category__name">
<h2>{_("optionsSiteWhitelistCategoryName")}</h2>
</legend>
<p className="category__description">
{_("optionsSiteWhitelistCategoryDescription")}
</p>
<label className="option option--inline">
<div className="option__control">
<input
name="siteWhitelistEnabled"
type="checkbox"
checked={
this.state.options?.siteWhitelistEnabled
}
onChange={this.handleInputChange}
/>
</div>
<div className="option__label">
{_("optionsSiteWhitelistEnabled")}
<span className="option__recommended">
{_("optionsOptionRecommended")}
</span>
</div>
<div className="option__description">
{_("optionsSiteWhitelistEnabledDescription")}
</div>
</label>
<div className="option">
<div className="option__label">
{_("optionsSiteWhitelistContent")}
</div>
<div className="option__control">
{this.state.options?.siteWhitelist && (
<Whitelist
items={this.state.options.siteWhitelist}
onChange={this.handleWhitelistChange}
/>
)}
</div>
</div>
</fieldset>
<div id="buttons">
<div id="status-line">
{this.state.hasSaved && _("optionsSaved")}
</div>
<button onClick={this.handleReset} type="button">
{_("optionsReset")}
</button>
<button
type="submit"
// @ts-ignore
default
disabled={!this.state.isFormValid}
>
{_("optionsSave")}
</button>
</div>
</form>
</div>
);
}
private handleReset() {
this.setState({
options: { ...defaultOptions }
});
}
private async handleFormSubmit(ev: React.FormEvent<HTMLFormElement>) {
ev.preventDefault();
this.form?.reportValidity();
try {
if (this.state.options) {
await options.setAll(this.state.options);
this.setState(
{
hasSaved: true
},
() => {
window.setTimeout(() => {
this.setState({
hasSaved: false
});
}, 1000);
}
);
}
} catch (err) {
logger.error("Failed to save options");
}
}
private handleFormChange(ev: React.FormEvent<HTMLFormElement>) {
ev.preventDefault();
const isFormValid = this.form?.checkValidity();
if (isFormValid !== undefined) {
this.setState({
isFormValid
});
}
}
private handleInputChange(ev: React.ChangeEvent<HTMLInputElement>) {
this.setState(currentState => {
if (currentState.options) {
currentState.options[ev.target.name] = getInputValue(ev.target);
}
return currentState;
});
}
private handleWhitelistChange(whitelist: WhitelistItemData[]) {
this.setState(currentState => {
if (currentState.options) {
currentState.options.siteWhitelist = whitelist;
}
return currentState;
});
}
}
ReactDOM.render(<OptionsApp />, document.querySelector("#root"));

View File

@@ -347,8 +347,9 @@ button.ghost:not(:hover) {
.whitelist__item { .whitelist__item {
align-items: center; align-items: center;
display: flex; display: flex;
gap: 5px;
height: 34px; height: 34px;
padding: 0 5px; padding: 0 10px;
} }
.whitelist__item:nth-child(odd) { .whitelist__item:nth-child(odd) {
@@ -368,23 +369,11 @@ button.ghost:not(:hover) {
white-space: nowrap; white-space: nowrap;
} }
.whitelist__item:not(.whitelist__item--selected) > .whitelist__title {
padding: 0 8px;
}
.whitelist__title + button {
margin-inline-end: 5px;
}
.whitelist__input-pattern { .whitelist__input-pattern {
font: inherit; font: inherit;
margin-inline-end: 1em;
width: -moz-available; width: -moz-available;
} }
.whitelist__user-agent {
margin-inline-end: 5px;
}
.whitelist__add-button { .whitelist__add-button {
margin-inline-end: auto; margin-inline-end: auto;
} }

View File

@@ -1,9 +1,10 @@
{ {
"extends": "../tsconfig" "extends": "../tsconfig",
, "compilerOptions": { "compilerOptions": {
"jsx": "react" "jsx": "react",
, "lib": [ "ESNext", "DOM", "DOM.Iterable" ] "lib": ["ESNext", "DOM", "DOM.Iterable"],
, "moduleResolution": "node" "moduleResolution": "node",
, "sourceMap": true "sourceMap": true,
"module": "esnext"
} }
} }

940
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.14.0", "@typescript-eslint/eslint-plugin": "^5.14.0",
"@typescript-eslint/parser": "^5.14.0", "@typescript-eslint/parser": "^5.14.0",
"esbuild-svelte": "^0.7.0",
"eslint": "^8.10.0", "eslint": "^8.10.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"fs-extra": "^10.0.1", "fs-extra": "^10.0.1",
@@ -32,6 +33,8 @@
"minimist": "^1.2.5", "minimist": "^1.2.5",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"selenium-webdriver": "^4.1.1", "selenium-webdriver": "^4.1.1",
"svelte": "^3.48.0",
"svelte-preprocess": "^4.10.6",
"typescript": "^4.6.2", "typescript": "^4.6.2",
"ws": "^8.5.0" "ws": "^8.5.0"
} }

View File

@@ -1,12 +1,13 @@
{ {
"compilerOptions": { "compilerOptions": {
"esModuleInterop": true "esModuleInterop": true,
, "module": "commonjs" "module": "commonjs",
, "noImplicitAny": true "noImplicitAny": true,
, "noUnusedParameters": true "noUnusedParameters": true,
, "removeComments": true "removeComments": true,
, "resolveJsonModule": true "resolveJsonModule": true,
, "target": "es6" "target": "es6",
, "strict": true "strict": true
} },
"exclude": ["node_modules/**/*", "dist/**/*"]
} }