diff --git a/app/package-lock.json b/app/package-lock.json index cb173f6..ff4e92c 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -364,27 +364,6 @@ } } }, - "big-integer": { - "version": "1.6.44", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.44.tgz", - "integrity": "sha512-7MzElZPTyJ2fNvBkPxtFQ2fWIkVmuzw41+BZHSzpEq3ymB2MfeKp1+yXl/tS75xCx+WnyV+yb0kp+K1C3UNwmQ==" - }, - "bplist-creator": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.0.8.tgz", - "integrity": "sha512-Za9JKzD6fjLC16oX2wsXfc+qBEhJBJB1YPInoAQpMLhDuj5aVOv1baGeIQSq1Fr3OCqzvsoQcSBSwGId/Ja2PA==", - "requires": { - "stream-buffers": "~2.2.0" - } - }, - "bplist-parser": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.2.0.tgz", - "integrity": "sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==", - "requires": { - "big-integer": "^1.6.44" - } - }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -902,11 +881,6 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, - "fast-srp-hap": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fast-srp-hap/-/fast-srp-hap-1.0.1.tgz", - "integrity": "sha1-N3Ek0Za8alFXquWze/X6NbtK0tk=" - }, "fill-range": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", @@ -1557,11 +1531,6 @@ "to-regex": "^3.0.1" } }, - "node-fetch": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", - "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==" - }, "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -2177,11 +2146,6 @@ } } }, - "stream-buffers": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", - "integrity": "sha1-kdX1Ew0c75bc+n9yaUUYh0HQnuQ=" - }, "stream-meter": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/stream-meter/-/stream-meter-1.0.4.tgz", @@ -2328,11 +2292,6 @@ "safe-buffer": "^5.0.1" } }, - "tweetnacl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.1.tgz", - "integrity": "sha512-kcoMoKTPYnoeS50tzoqjPY3Uv9axeuuFAZY9M/9zFnhoVvRfxz9K29IMPD7jGmt2c8SW7i3gT9WqDl2+nV7p4A==" - }, "type-check": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", diff --git a/app/package.json b/app/package.json index a34568f..4461683 100644 --- a/app/package.json +++ b/app/package.json @@ -11,14 +11,9 @@ "lint": "tslint -c tslint.json -p ." }, "dependencies": { - "bplist-creator": "0.0.8", - "bplist-parser": "^0.2.0", "castv2": "^0.1.10", "dnssd": "^0.4.1", - "fast-srp-hap": "^1.0.1", - "mime-types": "^2.1.24", - "node-fetch": "^2.6.0", - "tweetnacl": "^1.0.1" + "mime-types": "^2.1.24" }, "devDependencies": { "@types/dnssd": "^0.4.1", diff --git a/app/@types/bplist-creator/index.d.ts b/ext/@types/bplist-creator/index.d.ts similarity index 100% rename from app/@types/bplist-creator/index.d.ts rename to ext/@types/bplist-creator/index.d.ts diff --git a/app/@types/bplist-parser/index.d.ts b/ext/@types/bplist-parser/index.d.ts similarity index 100% rename from app/@types/bplist-parser/index.d.ts rename to ext/@types/bplist-parser/index.d.ts diff --git a/app/@types/fast-srp-hap/index.d.ts b/ext/@types/fast-srp-hap/index.d.ts similarity index 100% rename from app/@types/fast-srp-hap/index.d.ts rename to ext/@types/fast-srp-hap/index.d.ts diff --git a/ext/package-lock.json b/ext/package-lock.json index d905df6..deb7b44 100644 --- a/ext/package-lock.json +++ b/ext/package-lock.json @@ -1184,8 +1184,22 @@ "dev": true, "requires": { "tweetnacl": "^0.14.3" + }, + "dependencies": { + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "dev": true + } } }, + "big-integer": { + "version": "1.6.46", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.46.tgz", + "integrity": "sha512-Vj2TNtZ8Y0XaL6HCkzJiEqfykjtv/9wVCWIutMe+QVIXLPe2tCLEzULtYvcX9WRtmNIj3Jqi5tNjIsR0N4QOsg==", + "dev": true + }, "big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -1249,6 +1263,24 @@ } } }, + "bplist-creator": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.0.8.tgz", + "integrity": "sha512-Za9JKzD6fjLC16oX2wsXfc+qBEhJBJB1YPInoAQpMLhDuj5aVOv1baGeIQSq1Fr3OCqzvsoQcSBSwGId/Ja2PA==", + "dev": true, + "requires": { + "stream-buffers": "~2.2.0" + } + }, + "bplist-parser": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.2.0.tgz", + "integrity": "sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==", + "dev": true, + "requires": { + "big-integer": "^1.6.44" + } + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -4071,6 +4103,12 @@ "integrity": "sha512-q8BZ89jjc+mz08rSxROs8VsrBBcn1SIw1kq9NjolL509tkABRk9io01RAjSaEv1Xb2uFLt8VtRiZbGp5H8iDtg==", "dev": true }, + "fast-srp-hap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-srp-hap/-/fast-srp-hap-1.0.1.tgz", + "integrity": "sha1-N3Ek0Za8alFXquWze/X6NbtK0tk=", + "dev": true + }, "fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -8913,6 +8951,14 @@ "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" + }, + "dependencies": { + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "dev": true + } } }, "ssri": { @@ -8967,6 +9013,12 @@ "readable-stream": "^2.0.2" } }, + "stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha1-kdX1Ew0c75bc+n9yaUUYh0HQnuQ=", + "dev": true + }, "stream-each": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/stream-each/-/stream-each-1.2.3.tgz", @@ -9528,9 +9580,9 @@ } }, "tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.1.tgz", + "integrity": "sha512-kcoMoKTPYnoeS50tzoqjPY3Uv9axeuuFAZY9M/9zFnhoVvRfxz9K29IMPD7jGmt2c8SW7i3gT9WqDl2+nV7p4A==", "dev": true }, "type": { diff --git a/ext/package.json b/ext/package.json index cad23b7..86342d6 100644 --- a/ext/package.json +++ b/ext/package.json @@ -14,10 +14,14 @@ "@types/firefox-webext-browser": "^67.0.2", "@types/react": "^16.8.23", "@types/react-dom": "^16.8.5", + "bplist-creator": "0.0.8", + "bplist-parser": "^0.2.0", "copy-webpack-plugin": "^5.0.4", + "fast-srp-hap": "^1.0.1", "preact": "^8.4.2", "preact-compat": "^3.19.0", "ts-loader": "^6.0.4", + "tweetnacl": "^1.0.1", "web-ext": "^3.1.1", "webpack": "^4.38.0" } diff --git a/app/src/bridge/airplay/auth.ts b/ext/src/lib/auth/AirPlayAuth.ts similarity index 72% rename from app/src/bridge/airplay/auth.ts rename to ext/src/lib/auth/AirPlayAuth.ts index bb3918f..6736d46 100644 --- a/app/src/bridge/airplay/auth.ts +++ b/ext/src/lib/auth/AirPlayAuth.ts @@ -8,47 +8,23 @@ * - https://github.com/postlund/pyatv/blob/master/docs/airplay.rst */ +"use strict"; + +import { Buffer } from "buffer"; import crypto from "crypto"; + +import bplistCreate from "bplist-creator"; +import bplistParse from "bplist-parser"; import srp6a from "fast-srp-hap"; -import fetch, { Headers } from "node-fetch"; -import nacl from "tweetnacl"; -import bplist from "./bplist"; + +import AirPlayAuthCredentials from "./AirPlayAuthCredentials"; const AIRPLAY_PORT = 7000; const MIMETYPE_BPLIST = "application/x-apple-binary-plist"; -/** - * Client ID and keypair - */ -export class AirPlayAuthCredentials { - public clientId: string; - public clientSk: Uint8Array; - public clientPk: Uint8Array; - constructor ( - clientId?: string - , clientSk?: Uint8Array - , clientPk?: Uint8Array) { - - if (clientId && clientSk && clientPk) { - this.clientId = clientId; - this.clientSk = clientSk; - this.clientPk = clientPk; - } else { - // If specified without arguments, generate new credentials - const keyPair = nacl.sign.keyPair(); - - // Random 16-len string - this.clientId = crypto.randomBytes(8).toString("hex"); - - this.clientSk = keyPair.secretKey.slice(0, 32); - this.clientPk = keyPair.publicKey; - } - } -} - -export class AirPlayAuth { +export default class AirPlayAuth { private address: string; private credentials: AirPlayAuthCredentials; private baseUrl: URL; @@ -63,7 +39,7 @@ export class AirPlayAuth { /** * Begins pairing process. */ - public async beginPairing () { + public async beginPairing (): Promise { return this.sendPostRequest("/pair-pin-start"); } @@ -72,7 +48,7 @@ export class AirPlayAuth { * beginPairing(). Coordinates the three pairing stages and * manages request responses. */ - public async finishPairing (pin: string) { + public async finishPairing (pin: string): Promise { // Stage 1 response const { pk: serverPk , salt: serverSalt } = await this.pairSetupPin1(); @@ -81,13 +57,15 @@ export class AirPlayAuth { const srpParams = srp6a.params[2048]; srpParams.hash = "sha1"; + const { clientId, clientSk } = this.credentials; + // Create SRP client const srpClient = new srp6a.Client( - srpParams // Params - , serverSalt // Receiver salt - , Buffer.from(this.credentials.clientId) // Username - , Buffer.from(pin) // Password (receiver pin) - , Buffer.from(this.credentials.clientSk)); // Client secret key + srpParams // Params + , serverSalt // Receiver salt + , Buffer.from(clientId) // Username + , Buffer.from(pin) // Password (receiver pin) + , Buffer.from(Array.from(clientSk))); // Client secret key // Add receiver's public key srpClient.setB(serverPk); @@ -107,7 +85,7 @@ export class AirPlayAuth { * Triggering the receiver passcode display and receiving * its public key / salt. */ - public async pairSetupPin1 (): Promise { + private async pairSetupPin1 (): Promise { const [ response ] = await this.sendPostRequestBplist( "/pair-setup-pin" , { @@ -125,9 +103,10 @@ export class AirPlayAuth { * public keys, sending them to the receiver and receiving its * proof. */ - public async pairSetupPin2 ( + private async pairSetupPin2 ( pk: Buffer - , proof: Buffer): Promise { + , proof: Buffer) + : Promise { const [ response ] = await this.sendPostRequestBplist( "/pair-setup-pin" @@ -143,7 +122,7 @@ export class AirPlayAuth { * secret hash and sending it to the receiver. Receiver then * responds confirming the pairing is complete. */ - public async pairSetupPin3 ( + private async pairSetupPin3 ( sharedSecretHash: crypto.BinaryLike): Promise { // Create AES key @@ -182,14 +161,14 @@ export class AirPlayAuth { * Sends a POST request to receiver and returns the * response. */ - public async sendPostRequest ( + private async sendPostRequest ( path: string , contentType?: string - , data?: Buffer | string): Promise { + , data?: Buffer | string) + : Promise { // Create URL from base receiver URL and path const requestUrl = new URL(path, this.baseUrl); - const requestHeaders = new Headers({ "User-Agent": "AirPlay/320.20" }); @@ -209,26 +188,27 @@ export class AirPlayAuth { throw new Error(`AirPlay request error: ${response.status}`); } - return await response.buffer(); + return await response.arrayBuffer(); } /** * Encodes binary plist data, sends a POST request to * receiver, then decodes and returns the response. */ - public async sendPostRequestBplist ( + private async sendPostRequestBplist ( path: string - , data?: object): Promise { + , data?: object) + : Promise { // Convert data to compatible type const requestBody = data - ? bplist.create(data) + ? bplistCreate(data) : undefined; const response = await this.sendPostRequest( path, MIMETYPE_BPLIST, requestBody); // Convert response data to Buffer for bplist-parser - return bplist.parse.parseBuffer(response); + return bplistParse.parseBuffer(Buffer.from(response)); } } diff --git a/ext/src/lib/auth/AirPlayAuthCredentials.ts b/ext/src/lib/auth/AirPlayAuthCredentials.ts new file mode 100644 index 0000000..0f1f147 --- /dev/null +++ b/ext/src/lib/auth/AirPlayAuthCredentials.ts @@ -0,0 +1,34 @@ +"use strict"; + +import crypto from "crypto"; +import nacl from "tweetnacl"; + +/** + * Client ID and keypair. Existing ID and secret key can be + * passed as constructor parameters, otherwise new + * credentials are generated. + */ +export default class AirPlayAuthCredentials { + public clientId: string; + public clientSk: Uint8Array; + public clientPk: Uint8Array; + + constructor ( + clientId?: string + , clientSk?: Uint8Array + , clientPk?: Uint8Array) { + + if (clientId && clientSk && clientPk) { + this.clientId = clientId; + this.clientSk = clientSk; + this.clientPk = clientPk; + } else { + const keyPair = nacl.sign.keyPair(); + + // Random 16-len string + this.clientId = crypto.randomBytes(8).toString("hex"); + this.clientSk = keyPair.secretKey.slice(0, 32); + this.clientPk = keyPair.publicKey; + } + } +} diff --git a/ext/src/lib/auth/index.ts b/ext/src/lib/auth/index.ts new file mode 100644 index 0000000..f409f9f --- /dev/null +++ b/ext/src/lib/auth/index.ts @@ -0,0 +1,7 @@ +"use strict"; + +import AirPlayAuth from "./AirPlayAuth"; +import AirPlayAuthCredentials from "./AirPlayAuthCredentials"; + +export { AirPlayAuth + , AirPlayAuthCredentials }; diff --git a/ext/src/lib/base64.ts b/ext/src/lib/base64.ts new file mode 100644 index 0000000..157b66d --- /dev/null +++ b/ext/src/lib/base64.ts @@ -0,0 +1,12 @@ +"use strict"; + +export function encode (array: Uint8Array): string { + return btoa(String.fromCharCode(...array)); +} + +export function decode (encodedString: string): Uint8Array { + return new Uint8Array( + atob(encodedString) + .split("") + .map(c => c.charCodeAt(0))); +} diff --git a/ext/src/ui/options/AirPlayDeviceManager.tsx b/ext/src/ui/options/AirPlayDeviceManager.tsx new file mode 100644 index 0000000..2f16f70 --- /dev/null +++ b/ext/src/ui/options/AirPlayDeviceManager.tsx @@ -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 ( +
+
+ + + { props.data.isPaired || + } +
+
+
+ { props.data.name } + + { props.data.isPaired ? "Paired" : "Unpaired" } + +
+
+ { props.data.address } +
+
+
+ Credentials + + + + + + + + + + + + + + +
Client ID{ props.data.credentials.clientId }
Private key{ clientSk }
Public key{ clientPk }
+ +
+ + +
+
+
+ ); +}; + + +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 ( +
+
    + { this.state.devices.length + ? this.state.devices.map(device => ( + )) + :
    + No devices added +
    } +
+ +
+ + + + + + +
+
+ ); + } + + public async componentDidMount () { + this.setState({ + hasLoaded: true + , devices: await devices.getAll() + }); + } + + + private onFormSubmit (ev: React.FormEvent) { + ev.preventDefault(); + + if (ev.currentTarget.reportValidity()) { + this.onDeviceAdd(); + } + } + + private onFormInput (ev: React.ChangeEvent) { + 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) { + 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) { + this.setState({ + newDeviceAddress: ev.target.value + }); + } +} diff --git a/ext/src/ui/options/devices.ts b/ext/src/ui/options/devices.ts new file mode 100644 index 0000000..85c5d87 --- /dev/null +++ b/ext/src/ui/options/devices.ts @@ -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 { + 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 { + 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) + }); +} + diff --git a/ext/src/ui/options/styles/index.css b/ext/src/ui/options/index.css similarity index 74% rename from ext/src/ui/options/styles/index.css rename to ext/src/ui/options/index.css index 62be244..0456279 100644 --- a/ext/src/ui/options/styles/index.css +++ b/ext/src/ui/options/index.css @@ -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; +} diff --git a/ext/src/ui/options/index.html b/ext/src/ui/options/index.html index 5e9b10e..4754571 100644 --- a/ext/src/ui/options/index.html +++ b/ext/src/ui/options/index.html @@ -2,7 +2,7 @@ - + diff --git a/ext/src/ui/options/index.tsx b/ext/src/ui/options/index.tsx index 9954e7a..59f4076 100644 --- a/ext/src/ui/options/index.tsx +++ b/ext/src/ui/options/index.tsx @@ -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> { +
+ +

AirPlay

+
+

+ Management of AirPlay devices and API settings. +

+ +
+
+ Device manager +
+
+ +
+
+
+
{ this.state.hasSaved && _("optionsSaved") } diff --git a/ext/src/ui/options/platform_styles/index.ts b/ext/src/ui/options/platform_styles/index.ts new file mode 100644 index 0000000..4be19e6 --- /dev/null +++ b/ext/src/ui/options/platform_styles/index.ts @@ -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); + } + }); diff --git a/ext/src/ui/options/styles/linux.css b/ext/src/ui/options/platform_styles/style_linux.css similarity index 100% rename from ext/src/ui/options/styles/linux.css rename to ext/src/ui/options/platform_styles/style_linux.css diff --git a/ext/src/ui/options/styles/mac.css b/ext/src/ui/options/platform_styles/style_mac.css similarity index 69% rename from ext/src/ui/options/styles/mac.css rename to ext/src/ui/options/platform_styles/style_mac.css index ee97a1a..7b30e89 100644 --- a/ext/src/ui/options/styles/mac.css +++ b/ext/src/ui/options/platform_styles/style_mac.css @@ -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; -} diff --git a/ext/tsconfig.json b/ext/tsconfig.json index 044a820..9d4859b 100644 --- a/ext/tsconfig.json +++ b/ext/tsconfig.json @@ -1,5 +1,9 @@ { "extends": "../tsconfig" + , "include": [ + "./src/**/*" + , "./@types/**/*" + ] , "compilerOptions": { "jsx": "react" , "lib": [ "esnext", "dom" ] diff --git a/ext/webpack.config.js b/ext/webpack.config.js index 05eee30..85b0a92 100755 --- a/ext/webpack.config.js +++ b/ext/webpack.config.js @@ -86,4 +86,7 @@ module.exports = (env) => ({ , "react-dom": "preact-compat" } } + , node: { + fs: "empty" + } });