mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-08 08:39:59 +00:00
Move AirPlay auth module to extension and add initial options UI
This commit is contained in:
41
app/package-lock.json
generated
41
app/package-lock.json
generated
@@ -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": {
|
"brace-expansion": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||||
@@ -902,11 +881,6 @@
|
|||||||
"integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
|
"integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
|
||||||
"dev": true
|
"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": {
|
"fill-range": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
|
||||||
@@ -1557,11 +1531,6 @@
|
|||||||
"to-regex": "^3.0.1"
|
"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": {
|
"oauth-sign": {
|
||||||
"version": "0.9.0",
|
"version": "0.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
|
"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": {
|
"stream-meter": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/stream-meter/-/stream-meter-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/stream-meter/-/stream-meter-1.0.4.tgz",
|
||||||
@@ -2328,11 +2292,6 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"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": {
|
"type-check": {
|
||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
|
||||||
|
|||||||
@@ -11,14 +11,9 @@
|
|||||||
"lint": "tslint -c tslint.json -p ."
|
"lint": "tslint -c tslint.json -p ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bplist-creator": "0.0.8",
|
|
||||||
"bplist-parser": "^0.2.0",
|
|
||||||
"castv2": "^0.1.10",
|
"castv2": "^0.1.10",
|
||||||
"dnssd": "^0.4.1",
|
"dnssd": "^0.4.1",
|
||||||
"fast-srp-hap": "^1.0.1",
|
"mime-types": "^2.1.24"
|
||||||
"mime-types": "^2.1.24",
|
|
||||||
"node-fetch": "^2.6.0",
|
|
||||||
"tweetnacl": "^1.0.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/dnssd": "^0.4.1",
|
"@types/dnssd": "^0.4.1",
|
||||||
|
|||||||
58
ext/package-lock.json
generated
58
ext/package-lock.json
generated
@@ -1184,8 +1184,22 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"tweetnacl": "^0.14.3"
|
"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": {
|
"big.js": {
|
||||||
"version": "5.2.2",
|
"version": "5.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
|
"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": {
|
"brace-expansion": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||||
@@ -4071,6 +4103,12 @@
|
|||||||
"integrity": "sha512-q8BZ89jjc+mz08rSxROs8VsrBBcn1SIw1kq9NjolL509tkABRk9io01RAjSaEv1Xb2uFLt8VtRiZbGp5H8iDtg==",
|
"integrity": "sha512-q8BZ89jjc+mz08rSxROs8VsrBBcn1SIw1kq9NjolL509tkABRk9io01RAjSaEv1Xb2uFLt8VtRiZbGp5H8iDtg==",
|
||||||
"dev": true
|
"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": {
|
"fd-slicer": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||||
@@ -8913,6 +8951,14 @@
|
|||||||
"jsbn": "~0.1.0",
|
"jsbn": "~0.1.0",
|
||||||
"safer-buffer": "^2.0.2",
|
"safer-buffer": "^2.0.2",
|
||||||
"tweetnacl": "~0.14.0"
|
"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": {
|
"ssri": {
|
||||||
@@ -8967,6 +9013,12 @@
|
|||||||
"readable-stream": "^2.0.2"
|
"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": {
|
"stream-each": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/stream-each/-/stream-each-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/stream-each/-/stream-each-1.2.3.tgz",
|
||||||
@@ -9528,9 +9580,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tweetnacl": {
|
"tweetnacl": {
|
||||||
"version": "0.14.5",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.1.tgz",
|
||||||
"integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=",
|
"integrity": "sha512-kcoMoKTPYnoeS50tzoqjPY3Uv9axeuuFAZY9M/9zFnhoVvRfxz9K29IMPD7jGmt2c8SW7i3gT9WqDl2+nV7p4A==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
|
|||||||
@@ -14,10 +14,14 @@
|
|||||||
"@types/firefox-webext-browser": "^67.0.2",
|
"@types/firefox-webext-browser": "^67.0.2",
|
||||||
"@types/react": "^16.8.23",
|
"@types/react": "^16.8.23",
|
||||||
"@types/react-dom": "^16.8.5",
|
"@types/react-dom": "^16.8.5",
|
||||||
|
"bplist-creator": "0.0.8",
|
||||||
|
"bplist-parser": "^0.2.0",
|
||||||
"copy-webpack-plugin": "^5.0.4",
|
"copy-webpack-plugin": "^5.0.4",
|
||||||
|
"fast-srp-hap": "^1.0.1",
|
||||||
"preact": "^8.4.2",
|
"preact": "^8.4.2",
|
||||||
"preact-compat": "^3.19.0",
|
"preact-compat": "^3.19.0",
|
||||||
"ts-loader": "^6.0.4",
|
"ts-loader": "^6.0.4",
|
||||||
|
"tweetnacl": "^1.0.1",
|
||||||
"web-ext": "^3.1.1",
|
"web-ext": "^3.1.1",
|
||||||
"webpack": "^4.38.0"
|
"webpack": "^4.38.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,47 +8,23 @@
|
|||||||
* - https://github.com/postlund/pyatv/blob/master/docs/airplay.rst
|
* - https://github.com/postlund/pyatv/blob/master/docs/airplay.rst
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
import { Buffer } from "buffer";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
import bplistCreate from "bplist-creator";
|
||||||
|
import bplistParse from "bplist-parser";
|
||||||
import srp6a from "fast-srp-hap";
|
import srp6a from "fast-srp-hap";
|
||||||
import fetch, { Headers } from "node-fetch";
|
|
||||||
import nacl from "tweetnacl";
|
import AirPlayAuthCredentials from "./AirPlayAuthCredentials";
|
||||||
import bplist from "./bplist";
|
|
||||||
|
|
||||||
|
|
||||||
const AIRPLAY_PORT = 7000;
|
const AIRPLAY_PORT = 7000;
|
||||||
const MIMETYPE_BPLIST = "application/x-apple-binary-plist";
|
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 (
|
export default class AirPlayAuth {
|
||||||
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 {
|
|
||||||
private address: string;
|
private address: string;
|
||||||
private credentials: AirPlayAuthCredentials;
|
private credentials: AirPlayAuthCredentials;
|
||||||
private baseUrl: URL;
|
private baseUrl: URL;
|
||||||
@@ -63,7 +39,7 @@ export class AirPlayAuth {
|
|||||||
/**
|
/**
|
||||||
* Begins pairing process.
|
* Begins pairing process.
|
||||||
*/
|
*/
|
||||||
public async beginPairing () {
|
public async beginPairing (): Promise<any> {
|
||||||
return this.sendPostRequest("/pair-pin-start");
|
return this.sendPostRequest("/pair-pin-start");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +48,7 @@ export class AirPlayAuth {
|
|||||||
* beginPairing(). Coordinates the three pairing stages and
|
* beginPairing(). Coordinates the three pairing stages and
|
||||||
* manages request responses.
|
* manages request responses.
|
||||||
*/
|
*/
|
||||||
public async finishPairing (pin: string) {
|
public async finishPairing (pin: string): Promise<void> {
|
||||||
// Stage 1 response
|
// Stage 1 response
|
||||||
const { pk: serverPk
|
const { pk: serverPk
|
||||||
, salt: serverSalt } = await this.pairSetupPin1();
|
, salt: serverSalt } = await this.pairSetupPin1();
|
||||||
@@ -81,13 +57,15 @@ export class AirPlayAuth {
|
|||||||
const srpParams = srp6a.params[2048];
|
const srpParams = srp6a.params[2048];
|
||||||
srpParams.hash = "sha1";
|
srpParams.hash = "sha1";
|
||||||
|
|
||||||
|
const { clientId, clientSk } = this.credentials;
|
||||||
|
|
||||||
// Create SRP client
|
// Create SRP client
|
||||||
const srpClient = new srp6a.Client(
|
const srpClient = new srp6a.Client(
|
||||||
srpParams // Params
|
srpParams // Params
|
||||||
, serverSalt // Receiver salt
|
, serverSalt // Receiver salt
|
||||||
, Buffer.from(this.credentials.clientId) // Username
|
, Buffer.from(clientId) // Username
|
||||||
, Buffer.from(pin) // Password (receiver pin)
|
, Buffer.from(pin) // Password (receiver pin)
|
||||||
, Buffer.from(this.credentials.clientSk)); // Client secret key
|
, Buffer.from(Array.from(clientSk))); // Client secret key
|
||||||
|
|
||||||
// Add receiver's public key
|
// Add receiver's public key
|
||||||
srpClient.setB(serverPk);
|
srpClient.setB(serverPk);
|
||||||
@@ -107,7 +85,7 @@ export class AirPlayAuth {
|
|||||||
* Triggering the receiver passcode display and receiving
|
* Triggering the receiver passcode display and receiving
|
||||||
* its public key / salt.
|
* its public key / salt.
|
||||||
*/
|
*/
|
||||||
public async pairSetupPin1 (): Promise<any> {
|
private async pairSetupPin1 (): Promise<any> {
|
||||||
const [ response ] = await this.sendPostRequestBplist(
|
const [ response ] = await this.sendPostRequestBplist(
|
||||||
"/pair-setup-pin"
|
"/pair-setup-pin"
|
||||||
, {
|
, {
|
||||||
@@ -125,9 +103,10 @@ export class AirPlayAuth {
|
|||||||
* public keys, sending them to the receiver and receiving its
|
* public keys, sending them to the receiver and receiving its
|
||||||
* proof.
|
* proof.
|
||||||
*/
|
*/
|
||||||
public async pairSetupPin2 (
|
private async pairSetupPin2 (
|
||||||
pk: Buffer
|
pk: Buffer
|
||||||
, proof: Buffer): Promise<any> {
|
, proof: Buffer)
|
||||||
|
: Promise<any> {
|
||||||
|
|
||||||
const [ response ] = await this.sendPostRequestBplist(
|
const [ response ] = await this.sendPostRequestBplist(
|
||||||
"/pair-setup-pin"
|
"/pair-setup-pin"
|
||||||
@@ -143,7 +122,7 @@ export class AirPlayAuth {
|
|||||||
* secret hash and sending it to the receiver. Receiver then
|
* secret hash and sending it to the receiver. Receiver then
|
||||||
* responds confirming the pairing is complete.
|
* responds confirming the pairing is complete.
|
||||||
*/
|
*/
|
||||||
public async pairSetupPin3 (
|
private async pairSetupPin3 (
|
||||||
sharedSecretHash: crypto.BinaryLike): Promise<any> {
|
sharedSecretHash: crypto.BinaryLike): Promise<any> {
|
||||||
|
|
||||||
// Create AES key
|
// Create AES key
|
||||||
@@ -182,14 +161,14 @@ export class AirPlayAuth {
|
|||||||
* Sends a POST request to receiver and returns the
|
* Sends a POST request to receiver and returns the
|
||||||
* response.
|
* response.
|
||||||
*/
|
*/
|
||||||
public async sendPostRequest (
|
private async sendPostRequest (
|
||||||
path: string
|
path: string
|
||||||
, contentType?: string
|
, contentType?: string
|
||||||
, data?: Buffer | string): Promise<any> {
|
, data?: Buffer | string)
|
||||||
|
: Promise<any> {
|
||||||
|
|
||||||
// Create URL from base receiver URL and path
|
// Create URL from base receiver URL and path
|
||||||
const requestUrl = new URL(path, this.baseUrl);
|
const requestUrl = new URL(path, this.baseUrl);
|
||||||
|
|
||||||
const requestHeaders = new Headers({
|
const requestHeaders = new Headers({
|
||||||
"User-Agent": "AirPlay/320.20"
|
"User-Agent": "AirPlay/320.20"
|
||||||
});
|
});
|
||||||
@@ -209,26 +188,27 @@ export class AirPlayAuth {
|
|||||||
throw new Error(`AirPlay request error: ${response.status}`);
|
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
|
* Encodes binary plist data, sends a POST request to
|
||||||
* receiver, then decodes and returns the response.
|
* receiver, then decodes and returns the response.
|
||||||
*/
|
*/
|
||||||
public async sendPostRequestBplist (
|
private async sendPostRequestBplist (
|
||||||
path: string
|
path: string
|
||||||
, data?: object): Promise<any> {
|
, data?: object)
|
||||||
|
: Promise<any> {
|
||||||
|
|
||||||
// Convert data to compatible type
|
// Convert data to compatible type
|
||||||
const requestBody = data
|
const requestBody = data
|
||||||
? bplist.create(data)
|
? bplistCreate(data)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const response = await this.sendPostRequest(
|
const response = await this.sendPostRequest(
|
||||||
path, MIMETYPE_BPLIST, requestBody);
|
path, MIMETYPE_BPLIST, requestBody);
|
||||||
|
|
||||||
// Convert response data to Buffer for bplist-parser
|
// Convert response data to Buffer for bplist-parser
|
||||||
return bplist.parse.parseBuffer(response);
|
return bplistParse.parseBuffer(Buffer.from(response));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
34
ext/src/lib/auth/AirPlayAuthCredentials.ts
Normal file
34
ext/src/lib/auth/AirPlayAuthCredentials.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
ext/src/lib/auth/index.ts
Normal file
7
ext/src/lib/auth/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
import AirPlayAuth from "./AirPlayAuth";
|
||||||
|
import AirPlayAuthCredentials from "./AirPlayAuthCredentials";
|
||||||
|
|
||||||
|
export { AirPlayAuth
|
||||||
|
, AirPlayAuthCredentials };
|
||||||
12
ext/src/lib/base64.ts
Normal file
12
ext/src/lib/base64.ts
Normal file
@@ -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)));
|
||||||
|
}
|
||||||
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 {
|
.editable-list__add-button {
|
||||||
margin-inline-end: auto;
|
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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<link rel="stylesheet" href="styles/index.css">
|
<link rel="stylesheet" href="index.css">
|
||||||
<script src="bundle.js" defer></script>
|
<script src="bundle.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/* tslint:disable:max-line-length */
|
/* tslint:disable:max-line-length */
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
// Include platform-specific CSS
|
||||||
|
import "./platform_styles";
|
||||||
|
|
||||||
import React, { Component } from "react";
|
import React, { Component } from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
|
|
||||||
@@ -8,6 +11,7 @@ import defaultOptions from "../../defaultOptions";
|
|||||||
|
|
||||||
import Bridge from "./Bridge";
|
import Bridge from "./Bridge";
|
||||||
import EditableList from "./EditableList";
|
import EditableList from "./EditableList";
|
||||||
|
import AirPlayDeviceManager from "./AirPlayDeviceManager";
|
||||||
|
|
||||||
import bridge, { BridgeInfo } from "../../lib/bridge";
|
import bridge, { BridgeInfo } from "../../lib/bridge";
|
||||||
import options, { Options } from "../../lib/options";
|
import options, { Options } from "../../lib/options";
|
||||||
@@ -18,55 +22,6 @@ import { ReceiverSelectorType } from "../../background/receiverSelector";
|
|||||||
|
|
||||||
const _ = browser.i18n.getMessage;
|
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) {
|
function getInputValue (input: HTMLInputElement) {
|
||||||
switch (input.type) {
|
switch (input.type) {
|
||||||
@@ -356,6 +311,24 @@ class OptionsApp extends Component<{}, OptionsAppState> {
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</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="buttons">
|
||||||
<div id="status-line">
|
<div id="status-line">
|
||||||
{ this.state.hasSaved && _("optionsSaved") }
|
{ 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;
|
font: menu;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button:not(.small),
|
||||||
select,
|
select:not(.small),
|
||||||
input {
|
input:not(.small) {
|
||||||
font: inherit;
|
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 {
|
button:not([disabled]):hover:active {
|
||||||
color: -moz-mac-buttonactivetext;
|
color: -moz-mac-buttonactivetext;
|
||||||
}
|
}
|
||||||
@@ -17,16 +32,3 @@ button[default]:not([disabled]):not(:-moz-window-inactive) {
|
|||||||
button[default]:not(:hover):active {
|
button[default]:not(:hover):active {
|
||||||
color: ButtonText;
|
color: ButtonText;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
|
||||||
select {
|
|
||||||
height: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="checkbox"],
|
|
||||||
input[type="radio"] {
|
|
||||||
height: 16px;
|
|
||||||
margin-bottom: 1px;
|
|
||||||
margin-top: 1px;
|
|
||||||
width: 16px;
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"extends": "../tsconfig"
|
"extends": "../tsconfig"
|
||||||
|
, "include": [
|
||||||
|
"./src/**/*"
|
||||||
|
, "./@types/**/*"
|
||||||
|
]
|
||||||
, "compilerOptions": {
|
, "compilerOptions": {
|
||||||
"jsx": "react"
|
"jsx": "react"
|
||||||
, "lib": [ "esnext", "dom" ]
|
, "lib": [ "esnext", "dom" ]
|
||||||
|
|||||||
@@ -86,4 +86,7 @@ module.exports = (env) => ({
|
|||||||
, "react-dom": "preact-compat"
|
, "react-dom": "preact-compat"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
, node: {
|
||||||
|
fs: "empty"
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user