Move AirPlay auth module to extension and add initial options UI

This commit is contained in:
hensm
2019-10-31 04:52:42 +00:00
parent b3b14f782d
commit b93bdcad8c
21 changed files with 740 additions and 168 deletions

41
app/package-lock.json generated
View File

@@ -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",

View File

@@ -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
View File

@@ -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": {

View File

@@ -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"
} }

View File

@@ -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));
} }
} }

View 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;
}
}
}

View 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
View 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)));
}

View 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
});
}
}

View 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)
});
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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") }

View 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);
}
});

View File

@@ -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;
}

View File

@@ -1,5 +1,9 @@
{ {
"extends": "../tsconfig" "extends": "../tsconfig"
, "include": [
"./src/**/*"
, "./@types/**/*"
]
, "compilerOptions": { , "compilerOptions": {
"jsx": "react" "jsx": "react"
, "lib": [ "esnext", "dom" ] , "lib": [ "esnext", "dom" ]

View File

@@ -86,4 +86,7 @@ module.exports = (env) => ({
, "react-dom": "preact-compat" , "react-dom": "preact-compat"
} }
} }
, node: {
fs: "empty"
}
}); });