mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-10 17:49:58 +00:00
Move AirPlay auth module to extension and add initial options UI
This commit is contained in:
292
ext/src/ui/options/AirPlayDeviceManager.tsx
Normal file
292
ext/src/ui/options/AirPlayDeviceManager.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
"use strict";
|
||||
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { AirPlayAuthCredentials } from "../../lib/auth"
|
||||
import * as base64 from "../../lib/base64";
|
||||
import * as devices from "./devices";
|
||||
|
||||
|
||||
//const _ = browser.i18n.getMessage;
|
||||
|
||||
|
||||
interface AirPlayDeviceProps {
|
||||
data: devices.Device;
|
||||
|
||||
onRemove: (device: devices.Device) => void;
|
||||
onRegenCredentials: (device: devices.Device) => void;
|
||||
onPairCredentials: (device: devices.Device) => void;
|
||||
}
|
||||
|
||||
const AirPlayDevice = (props: AirPlayDeviceProps) => {
|
||||
const clientSk = base64.encode(props.data.credentials.clientSk);
|
||||
const clientPk = base64.encode(props.data.credentials.clientPk);
|
||||
|
||||
const pairedStatusClassName = !props.data.isPaired
|
||||
? "device__paired-status"
|
||||
: "device__paired-status device__paired-status--paired";
|
||||
|
||||
|
||||
function copyCredentials () {
|
||||
navigator.clipboard.writeText(
|
||||
` Client ID: ${props.data.credentials.clientId}\n`
|
||||
+ `Private Key: ${clientSk}\n`
|
||||
+ ` Public Key: ${clientPk}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="device">
|
||||
<div className="device__actions">
|
||||
<button className="device__action"
|
||||
onClick={ () => {
|
||||
props.onRemove(props.data);
|
||||
}}>
|
||||
Remove Device
|
||||
</button>
|
||||
|
||||
{ props.data.isPaired ||
|
||||
<button className="device__action"
|
||||
onClick={ () => {
|
||||
props.onPairCredentials(props.data);
|
||||
}}>
|
||||
Pair Device
|
||||
</button> }
|
||||
</div>
|
||||
<div className="device__meta">
|
||||
<div className="device__name">
|
||||
{ props.data.name }
|
||||
<span className={pairedStatusClassName}>
|
||||
{ props.data.isPaired ? "Paired" : "Unpaired" }
|
||||
</span>
|
||||
</div>
|
||||
<div className="device__address">
|
||||
{ props.data.address }
|
||||
</div>
|
||||
</div>
|
||||
<details className="device__credentials">
|
||||
<summary>Credentials</summary>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Client ID</th>
|
||||
<td>{ props.data.credentials.clientId }</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Private key</th>
|
||||
<td>{ clientSk }</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Public key</th>
|
||||
<td>{ clientPk }</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div className="device__credentials-actions">
|
||||
<button className="small"
|
||||
onClick={ () => {
|
||||
props.onRegenCredentials(props.data);
|
||||
}}>
|
||||
Regenerate Credentials
|
||||
</button>
|
||||
<button className="small"
|
||||
onClick={ copyCredentials }>
|
||||
Copy to Clipboard
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
interface AirPlayDeviceManagerProps {}
|
||||
interface AirPlayDeviceManagerState {
|
||||
hasLoaded: boolean;
|
||||
isFormValid: boolean;
|
||||
devices: devices.Device[];
|
||||
|
||||
newDeviceName: string;
|
||||
newDeviceAddress: string;
|
||||
newDeviceAddressSuggestion: string;
|
||||
}
|
||||
|
||||
export default class AirPlayDeviceManager extends Component<
|
||||
AirPlayDeviceManagerProps, AirPlayDeviceManagerState> {
|
||||
|
||||
constructor (props: AirPlayDeviceManagerProps) {
|
||||
super(props);
|
||||
|
||||
this.onFormSubmit = this.onFormSubmit.bind(this);
|
||||
this.onFormInput = this.onFormInput.bind(this);
|
||||
|
||||
this.onDeviceAdd = this.onDeviceAdd.bind(this);
|
||||
this.onDeviceRemove = this.onDeviceRemove.bind(this);
|
||||
this.onDeviceRegenCredentials = this.onDeviceRegenCredentials.bind(this);
|
||||
this.onDevicePairCredentials = this.onDevicePairCredentials.bind(this);
|
||||
|
||||
this.onNewDeviceNameChange = this.onNewDeviceNameChange.bind(this);
|
||||
this.onNewDeviceAddressChange = this.onNewDeviceAddressChange.bind(this);
|
||||
|
||||
|
||||
this.state = {
|
||||
hasLoaded: false
|
||||
, isFormValid: false
|
||||
, devices: []
|
||||
|
||||
, newDeviceName: ""
|
||||
, newDeviceAddress: ""
|
||||
, newDeviceAddressSuggestion: ""
|
||||
};
|
||||
}
|
||||
|
||||
public render () {
|
||||
if (!this.state.hasLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="device-manager">
|
||||
<ul className="device-manager__devices">
|
||||
{ this.state.devices.length
|
||||
? this.state.devices.map(device => (
|
||||
<AirPlayDevice data={ device }
|
||||
onRemove={ this.onDeviceRemove }
|
||||
onRegenCredentials={this.onDeviceRegenCredentials }
|
||||
onPairCredentials={ this.onDevicePairCredentials } /> ))
|
||||
: <div className="device-manager__no-devices">
|
||||
No devices added
|
||||
</div> }
|
||||
</ul>
|
||||
|
||||
<form className="device-manager-new"
|
||||
onSubmit={this.onFormSubmit}
|
||||
onInput={this.onFormInput}>
|
||||
|
||||
<label className="device-manager-new__label">
|
||||
<div className="device-manager-new__input-label">
|
||||
Device name
|
||||
</div>
|
||||
<input className="device-manager-new__input-name"
|
||||
type="text"
|
||||
//required
|
||||
name="newDeviceName"
|
||||
placeholder="Living Room"
|
||||
value={ this.state.newDeviceName }
|
||||
onChange={ this.onNewDeviceNameChange } />
|
||||
</label>
|
||||
|
||||
<label className="device-manager-new__label">
|
||||
<div className="device-manager-new__input-label">
|
||||
Device address
|
||||
</div>
|
||||
<input className="device-manager-new__input-address"
|
||||
type="text"
|
||||
pattern="^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]$"
|
||||
name="newDeviceAddress"
|
||||
placeholder={
|
||||
(this.state.newDeviceName
|
||||
&& this.state.newDeviceAddressSuggestion)
|
||||
|| "living-room.local" }
|
||||
value={ this.state.newDeviceAddress }
|
||||
onChange={ this.onNewDeviceAddressChange } />
|
||||
</label>
|
||||
|
||||
<button className="device-manager-new__submit"
|
||||
type="submit"
|
||||
disabled={ !this.state.isFormValid }>
|
||||
Add Device
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public async componentDidMount () {
|
||||
this.setState({
|
||||
hasLoaded: true
|
||||
, devices: await devices.getAll()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private onFormSubmit (ev: React.FormEvent<HTMLFormElement>) {
|
||||
ev.preventDefault();
|
||||
|
||||
if (ev.currentTarget.reportValidity()) {
|
||||
this.onDeviceAdd();
|
||||
}
|
||||
}
|
||||
|
||||
private onFormInput (ev: React.ChangeEvent<HTMLFormElement>) {
|
||||
this.setState({
|
||||
isFormValid: ev.currentTarget.reportValidity()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private async onDeviceAdd () {
|
||||
const device: devices.Device = {
|
||||
name: this.state.newDeviceName
|
||||
, address: this.state.newDeviceAddress
|
||||
, credentials: new AirPlayAuthCredentials()
|
||||
, isPaired: false
|
||||
};
|
||||
|
||||
// Use generated address if user left it blank
|
||||
if (!this.state.newDeviceAddress) {
|
||||
device.address = this.state.newDeviceAddressSuggestion;
|
||||
}
|
||||
|
||||
await devices.add(device);
|
||||
|
||||
this.setState({
|
||||
devices: await devices.getAll()
|
||||
});
|
||||
}
|
||||
|
||||
private onDeviceRemove (device: devices.Device) {
|
||||
this.setState(state => ({
|
||||
devices: state.devices.filter(d => d.name !== device.name)
|
||||
}));
|
||||
|
||||
devices.remove(device);
|
||||
}
|
||||
|
||||
private onDeviceRegenCredentials (device: devices.Device) {
|
||||
this.setState(state => {
|
||||
devices: state.devices.map(d => {
|
||||
if (d.name === device.name) {
|
||||
d.credentials = new AirPlayAuthCredentials();
|
||||
}
|
||||
|
||||
return d;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private onDevicePairCredentials () {}
|
||||
|
||||
|
||||
private onNewDeviceNameChange (ev: React.ChangeEvent<HTMLInputElement>) {
|
||||
this.setState({
|
||||
newDeviceName: ev.target.value
|
||||
});
|
||||
|
||||
if (!this.state.newDeviceAddress) {
|
||||
const formattedName = ev.target.value
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.replace(/ /g, "-")
|
||||
.replace(/[^a-zA-Z0-9-]/g, "");
|
||||
|
||||
this.setState({
|
||||
newDeviceAddressSuggestion: `${formattedName}.local`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private onNewDeviceAddressChange (ev: React.ChangeEvent<HTMLInputElement>) {
|
||||
this.setState({
|
||||
newDeviceAddress: ev.target.value
|
||||
});
|
||||
}
|
||||
}
|
||||
84
ext/src/ui/options/devices.ts
Normal file
84
ext/src/ui/options/devices.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
"use strict";
|
||||
|
||||
/* TEMPORARY */
|
||||
|
||||
import { TypedStorageArea } from "../../lib/typedStorage";
|
||||
import { AirPlayAuthCredentials } from "../../lib/auth";
|
||||
|
||||
|
||||
export interface Device {
|
||||
name: string;
|
||||
address: string;
|
||||
isPaired: boolean;
|
||||
credentials: AirPlayAuthCredentials;
|
||||
}
|
||||
|
||||
interface DeviceEncoded extends Omit<Device, "credentials"> {
|
||||
credentials: {
|
||||
clientId: string;
|
||||
clientSk: number[];
|
||||
clientPk: number[];
|
||||
}
|
||||
}
|
||||
|
||||
const storageArea = new TypedStorageArea<{
|
||||
devices: DeviceEncoded[];
|
||||
}>(browser.storage.sync);
|
||||
|
||||
|
||||
function encode (device: Device) {
|
||||
const encoded = device as unknown as DeviceEncoded;
|
||||
encoded.credentials.clientSk = Array.from(device.credentials.clientSk);
|
||||
encoded.credentials.clientPk = Array.from(device.credentials.clientPk);
|
||||
|
||||
return encoded;
|
||||
}
|
||||
|
||||
function decode (device: DeviceEncoded) {
|
||||
const decoded = device as unknown as Device;
|
||||
decoded.credentials.clientSk = new Uint8Array(device.credentials.clientSk);
|
||||
decoded.credentials.clientPk = new Uint8Array(device.credentials.clientPk);
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
|
||||
export async function getAll (): Promise<Device[]> {
|
||||
const { devices } = await storageArea.get("devices");
|
||||
|
||||
if (!devices) {
|
||||
await browser.storage.sync.set({
|
||||
devices: []
|
||||
});
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return devices.map(decode);
|
||||
}
|
||||
|
||||
export async function add (device: Device) {
|
||||
const devices = await getAll();
|
||||
|
||||
if (devices.some(dv => dv.name === device.name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await browser.storage.sync.set({
|
||||
devices: [
|
||||
...devices.map(encode)
|
||||
, encode(device)
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
export async function remove (device: Device) {
|
||||
const devices = await getAll();
|
||||
|
||||
await browser.storage.sync.set({
|
||||
devices: devices
|
||||
.filter(dv => dv.name !== device.name)
|
||||
.map(encode)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -326,3 +326,128 @@
|
||||
.editable-list__add-button {
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
|
||||
.device:not(:first-child) {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 15px;
|
||||
}
|
||||
|
||||
.device__meta {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.device__name {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-size: 16px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.device__paired-status {
|
||||
background-color: var(--secondary-color);
|
||||
border-radius: 9999px;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
padding: 1px 5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.device__paired-status--paired {
|
||||
background-color: #058b00;
|
||||
}
|
||||
|
||||
.device__address {
|
||||
font-size: small;
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
|
||||
.device__credentials {
|
||||
margin-top: 5px;
|
||||
padding: 5px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.device__credentials > table {
|
||||
border-collapse: collapse;
|
||||
font-size: smaller;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.device__credentials > table th {
|
||||
text-align: end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device__credentials > table td {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.device__credentials > table th,
|
||||
.device__credentials > table td {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.device__credentials-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
|
||||
.device__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.device__action {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
.device-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.device-manager__devices {
|
||||
border: 1px solid var(--border-color);
|
||||
margin: initial;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.device-manager__no-devices {
|
||||
align-self: center;
|
||||
padding: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.device-manager-new {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
max-width: 35%;
|
||||
}
|
||||
|
||||
.device-manager-new__label {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.device-manager-new__input-label {
|
||||
margin-bottom: -3px;
|
||||
}
|
||||
|
||||
.device-manager-new__submit {
|
||||
margin-top: 5px;
|
||||
grid-column-start: 2;
|
||||
align-self: flex-end;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="styles/index.css">
|
||||
<link rel="stylesheet" href="index.css">
|
||||
<script src="bundle.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/* tslint:disable:max-line-length */
|
||||
"use strict";
|
||||
|
||||
// Include platform-specific CSS
|
||||
import "./platform_styles";
|
||||
|
||||
import React, { Component } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
@@ -8,6 +11,7 @@ import defaultOptions from "../../defaultOptions";
|
||||
|
||||
import Bridge from "./Bridge";
|
||||
import EditableList from "./EditableList";
|
||||
import AirPlayDeviceManager from "./AirPlayDeviceManager";
|
||||
|
||||
import bridge, { BridgeInfo } from "../../lib/bridge";
|
||||
import options, { Options } from "../../lib/options";
|
||||
@@ -18,55 +22,6 @@ import { ReceiverSelectorType } from "../../background/receiverSelector";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Fix issue with input[type="number"] height
|
||||
case "linux": {
|
||||
link.href = "styles/linux.css";
|
||||
|
||||
const input = document.createElement("input");
|
||||
const inputWrapper = document.createElement("div");
|
||||
|
||||
inputWrapper.append(input);
|
||||
document.documentElement.append(inputWrapper);
|
||||
|
||||
input.type = "text";
|
||||
const textInputHeight = window.getComputedStyle(input).height;
|
||||
input.type = "number";
|
||||
const numberInputHeight = window.getComputedStyle(input).height;
|
||||
|
||||
inputWrapper.remove();
|
||||
|
||||
if (numberInputHeight !== textInputHeight) {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
input[type="number"] {
|
||||
height: ${textInputHeight};
|
||||
}
|
||||
`;
|
||||
|
||||
document.body.append(style);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (link.href) {
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function getInputValue (input: HTMLInputElement) {
|
||||
switch (input.type) {
|
||||
@@ -356,6 +311,24 @@ class OptionsApp extends Component<{}, OptionsAppState> {
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="category">
|
||||
<legend className="category__name">
|
||||
<h2>AirPlay</h2>
|
||||
</legend>
|
||||
<p className="category__description">
|
||||
Management of AirPlay devices and API settings.
|
||||
</p>
|
||||
|
||||
<div className="option">
|
||||
<div className="option__label">
|
||||
Device manager
|
||||
</div>
|
||||
<div className="option__control">
|
||||
<AirPlayDeviceManager />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div id="buttons">
|
||||
<div id="status-line">
|
||||
{ this.state.hasSaved && _("optionsSaved") }
|
||||
|
||||
46
ext/src/ui/options/platform_styles/index.ts
Normal file
46
ext/src/ui/options/platform_styles/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
"use strict";
|
||||
|
||||
browser.runtime.getPlatformInfo()
|
||||
.then(platformInfo => {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
|
||||
switch (platformInfo.os) {
|
||||
case "mac": {
|
||||
link.href = "platform_styles/style_mac.css";
|
||||
break;
|
||||
}
|
||||
|
||||
case "linux": {
|
||||
link.href = "platform_styles/style_linux.css";
|
||||
|
||||
const input = document.createElement("input");
|
||||
const inputWrapper = document.createElement("div");
|
||||
|
||||
inputWrapper.append(input);
|
||||
document.documentElement.append(inputWrapper);
|
||||
|
||||
input.type = "text";
|
||||
const textInputHeight = window.getComputedStyle(input).height;
|
||||
input.type = "number";
|
||||
const numberInputHeight = window.getComputedStyle(input).height;
|
||||
|
||||
inputWrapper.remove();
|
||||
|
||||
if (numberInputHeight !== textInputHeight) {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
input[type="number"] {
|
||||
height: ${textInputHeight};
|
||||
}
|
||||
`;
|
||||
|
||||
document.body.append(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (link.href) {
|
||||
document.head.append(link);
|
||||
}
|
||||
});
|
||||
@@ -2,12 +2,27 @@ body {
|
||||
font: menu;
|
||||
}
|
||||
|
||||
button,
|
||||
select,
|
||||
input {
|
||||
button:not(.small),
|
||||
select:not(.small),
|
||||
input:not(.small) {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
|
||||
button:not(.small),
|
||||
select:not(.small) {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
input[type="checkbox"]:not(.small),
|
||||
input[type="radio"]:not(.small) {
|
||||
height: 16px;
|
||||
margin-bottom: 1px;
|
||||
margin-top: 1px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
|
||||
button:not([disabled]):hover:active {
|
||||
color: -moz-mac-buttonactivetext;
|
||||
}
|
||||
@@ -17,16 +32,3 @@ button[default]:not([disabled]):not(:-moz-window-inactive) {
|
||||
button[default]:not(:hover):active {
|
||||
color: ButtonText;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
height: 16px;
|
||||
margin-bottom: 1px;
|
||||
margin-top: 1px;
|
||||
width: 16px;
|
||||
}
|
||||
Reference in New Issue
Block a user