Remove moonlight-common subproject

This commit is contained in:
Cameron Gutman
2019-08-20 18:51:13 -07:00
parent 61f8fa7c5a
commit 235a0635be
164 changed files with 6 additions and 730 deletions

View File

@@ -0,0 +1,25 @@
package com.limelight;
import java.io.IOException;
import java.util.logging.FileHandler;
import java.util.logging.Logger;
public class LimeLog {
private static final Logger LOGGER = Logger.getLogger(LimeLog.class.getName());
public static void info(String msg) {
LOGGER.info(msg);
}
public static void warning(String msg) {
LOGGER.warning(msg);
}
public static void severe(String msg) {
LOGGER.severe(msg);
}
public static void setFileHandler(String fileName) throws IOException {
LOGGER.addHandler(new FileHandler(fileName));
}
}

View File

@@ -0,0 +1,24 @@
package com.limelight.nvstream;
import java.security.cert.X509Certificate;
import javax.crypto.SecretKey;
public class ConnectionContext {
public String serverAddress;
public X509Certificate serverCert;
public StreamConfiguration streamConfig;
public NvConnectionListener connListener;
public SecretKey riKey;
public int riKeyId;
// This is the version quad from the appversion tag of /serverinfo
public String serverAppVersion;
public String serverGfeVersion;
public int negotiatedWidth, negotiatedHeight;
public int negotiatedFps;
public boolean negotiatedHdr;
public int videoCapabilities;
}

View File

@@ -0,0 +1,345 @@
package com.limelight.nvstream;
import android.app.ActivityManager;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.util.concurrent.Semaphore;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import org.xmlpull.v1.XmlPullParserException;
import com.limelight.LimeLog;
import com.limelight.nvstream.av.audio.AudioRenderer;
import com.limelight.nvstream.av.video.VideoDecoderRenderer;
import com.limelight.nvstream.http.GfeHttpResponseException;
import com.limelight.nvstream.http.LimelightCryptoProvider;
import com.limelight.nvstream.http.NvApp;
import com.limelight.nvstream.http.NvHTTP;
import com.limelight.nvstream.http.PairingManager;
import com.limelight.nvstream.input.MouseButtonPacket;
import com.limelight.nvstream.jni.MoonBridge;
public class NvConnection {
// Context parameters
private String host;
private LimelightCryptoProvider cryptoProvider;
private String uniqueId;
private ConnectionContext context;
private static Semaphore connectionAllowed = new Semaphore(1);
private final boolean isMonkey;
public NvConnection(String host, String uniqueId, StreamConfiguration config, LimelightCryptoProvider cryptoProvider, X509Certificate serverCert)
{
this.host = host;
this.cryptoProvider = cryptoProvider;
this.uniqueId = uniqueId;
this.context = new ConnectionContext();
this.context.streamConfig = config;
this.context.serverCert = serverCert;
try {
// This is unique per connection
this.context.riKey = generateRiAesKey();
} catch (NoSuchAlgorithmException e) {
// Should never happen
e.printStackTrace();
}
this.context.riKeyId = generateRiKeyId();
this.isMonkey = ActivityManager.isUserAMonkey();
}
private static SecretKey generateRiAesKey() throws NoSuchAlgorithmException {
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
// RI keys are 128 bits
keyGen.init(128);
return keyGen.generateKey();
}
private static int generateRiKeyId() {
return new SecureRandom().nextInt();
}
public void stop() {
// Interrupt any pending connection. This is thread-safe.
MoonBridge.interruptConnection();
// Moonlight-core is not thread-safe with respect to connection start and stop, so
// we must not invoke that functionality in parallel.
synchronized (MoonBridge.class) {
MoonBridge.stopConnection();
MoonBridge.cleanupBridge();
}
// Now a pending connection can be processed
connectionAllowed.release();
}
private boolean startApp() throws XmlPullParserException, IOException
{
NvHTTP h = new NvHTTP(context.serverAddress, uniqueId, context.serverCert, cryptoProvider);
String serverInfo = h.getServerInfo();
context.serverAppVersion = h.getServerVersion(serverInfo);
if (context.serverAppVersion == null) {
context.connListener.displayMessage("Server version malformed");
return false;
}
// May be missing for older servers
context.serverGfeVersion = h.getGfeVersion(serverInfo);
if (h.getPairState(serverInfo) != PairingManager.PairState.PAIRED) {
context.connListener.displayMessage("Device not paired with computer");
return false;
}
context.negotiatedHdr = context.streamConfig.getEnableHdr();
if ((h.getServerCodecModeSupport(serverInfo) & 0x200) == 0 && context.negotiatedHdr) {
context.connListener.displayTransientMessage("Your GPU does not support streaming HDR. The stream will be SDR.");
context.negotiatedHdr = false;
}
//
// Decide on negotiated stream parameters now
//
// Check for a supported stream resolution
if (context.streamConfig.getHeight() >= 2160 && !h.supports4K(serverInfo)) {
// Client wants 4K but the server can't do it
context.connListener.displayTransientMessage("You must update GeForce Experience to stream in 4K. The stream will be 1080p.");
// Lower resolution to 1080p
context.negotiatedWidth = 1920;
context.negotiatedHeight = 1080;
context.negotiatedFps = context.streamConfig.getRefreshRate();
}
else {
// Take what the client wanted
context.negotiatedWidth = context.streamConfig.getWidth();
context.negotiatedHeight = context.streamConfig.getHeight();
context.negotiatedFps = context.streamConfig.getRefreshRate();
}
//
// Video stream format will be decided during the RTSP handshake
//
NvApp app = context.streamConfig.getApp();
// If the client did not provide an exact app ID, do a lookup with the applist
if (!context.streamConfig.getApp().isInitialized()) {
LimeLog.info("Using deprecated app lookup method - Please specify an app ID in your StreamConfiguration instead");
app = h.getAppByName(context.streamConfig.getApp().getAppName());
if (app == null) {
context.connListener.displayMessage("The app " + context.streamConfig.getApp().getAppName() + " is not in GFE app list");
return false;
}
}
// If there's a game running, resume it
if (h.getCurrentGame(serverInfo) != 0) {
try {
if (h.getCurrentGame(serverInfo) == app.getAppId()) {
if (!h.resumeApp(context)) {
context.connListener.displayMessage("Failed to resume existing session");
return false;
}
} else {
return quitAndLaunch(h, context);
}
} catch (GfeHttpResponseException e) {
if (e.getErrorCode() == 470) {
// This is the error you get when you try to resume a session that's not yours.
// Because this is fairly common, we'll display a more detailed message.
context.connListener.displayMessage("This session wasn't started by this device," +
" so it cannot be resumed. End streaming on the original " +
"device or the PC itself and try again. (Error code: "+e.getErrorCode()+")");
return false;
}
else if (e.getErrorCode() == 525) {
context.connListener.displayMessage("The application is minimized. Resume it on the PC manually or " +
"quit the session and start streaming again.");
return false;
} else {
throw e;
}
}
LimeLog.info("Resumed existing game session");
return true;
}
else {
return launchNotRunningApp(h, context);
}
}
protected boolean quitAndLaunch(NvHTTP h, ConnectionContext context) throws IOException,
XmlPullParserException {
try {
if (!h.quitApp()) {
context.connListener.displayMessage("Failed to quit previous session! You must quit it manually");
return false;
}
} catch (GfeHttpResponseException e) {
if (e.getErrorCode() == 599) {
context.connListener.displayMessage("This session wasn't started by this device," +
" so it cannot be quit. End streaming on the original " +
"device or the PC itself. (Error code: "+e.getErrorCode()+")");
return false;
}
else {
throw e;
}
}
return launchNotRunningApp(h, context);
}
private boolean launchNotRunningApp(NvHTTP h, ConnectionContext context)
throws IOException, XmlPullParserException {
// Launch the app since it's not running
if (!h.launchApp(context, context.streamConfig.getApp().getAppId(), context.negotiatedHdr)) {
context.connListener.displayMessage("Failed to launch application");
return false;
}
LimeLog.info("Launched new game session");
return true;
}
public void start(final AudioRenderer audioRenderer, final VideoDecoderRenderer videoDecoderRenderer, final NvConnectionListener connectionListener)
{
new Thread(new Runnable() {
public void run() {
context.connListener = connectionListener;
context.videoCapabilities = videoDecoderRenderer.getCapabilities();
String appName = context.streamConfig.getApp().getAppName();
context.serverAddress = host;
context.connListener.stageStarting(appName);
try {
if (!startApp()) {
context.connListener.stageFailed(appName, 0);
return;
}
context.connListener.stageComplete(appName);
} catch (XmlPullParserException | IOException e) {
e.printStackTrace();
context.connListener.displayMessage(e.getMessage());
context.connListener.stageFailed(appName, 0);
return;
}
ByteBuffer ib = ByteBuffer.allocate(16);
ib.putInt(context.riKeyId);
// Acquire the connection semaphore to ensure we only have one
// connection going at once.
try {
connectionAllowed.acquire();
} catch (InterruptedException e) {
context.connListener.displayMessage(e.getMessage());
context.connListener.stageFailed(appName, 0);
return;
}
// Moonlight-core is not thread-safe with respect to connection start and stop, so
// we must not invoke that functionality in parallel.
synchronized (MoonBridge.class) {
MoonBridge.setupBridge(videoDecoderRenderer, audioRenderer, connectionListener);
int ret = MoonBridge.startConnection(context.serverAddress,
context.serverAppVersion, context.serverGfeVersion,
context.negotiatedWidth, context.negotiatedHeight,
context.negotiatedFps, context.streamConfig.getBitrate(),
context.streamConfig.getMaxPacketSize(),
context.streamConfig.getRemote(), context.streamConfig.getAudioConfiguration(),
context.streamConfig.getHevcSupported(),
context.negotiatedHdr,
context.streamConfig.getHevcBitratePercentageMultiplier(),
context.streamConfig.getClientRefreshRateX100(),
context.riKey.getEncoded(), ib.array(),
context.videoCapabilities);
if (ret != 0) {
// LiStartConnection() failed, so the caller is not expected
// to stop the connection themselves. We need to release their
// semaphore count for them.
connectionAllowed.release();
}
}
}
}).start();
}
public void sendMouseMove(final short deltaX, final short deltaY)
{
if (!isMonkey) {
MoonBridge.sendMouseMove(deltaX, deltaY);
}
}
public void sendMouseButtonDown(final byte mouseButton)
{
if (!isMonkey) {
MoonBridge.sendMouseButton(MouseButtonPacket.PRESS_EVENT, mouseButton);
}
}
public void sendMouseButtonUp(final byte mouseButton)
{
if (!isMonkey) {
MoonBridge.sendMouseButton(MouseButtonPacket.RELEASE_EVENT, mouseButton);
}
}
public void sendControllerInput(final short controllerNumber,
final short activeGamepadMask, final short buttonFlags,
final byte leftTrigger, final byte rightTrigger,
final short leftStickX, final short leftStickY,
final short rightStickX, final short rightStickY)
{
if (!isMonkey) {
MoonBridge.sendMultiControllerInput(controllerNumber, activeGamepadMask, buttonFlags,
leftTrigger, rightTrigger, leftStickX, leftStickY, rightStickX, rightStickY);
}
}
public void sendControllerInput(final short buttonFlags,
final byte leftTrigger, final byte rightTrigger,
final short leftStickX, final short leftStickY,
final short rightStickX, final short rightStickY)
{
if (!isMonkey) {
MoonBridge.sendControllerInput(buttonFlags, leftTrigger, rightTrigger, leftStickX,
leftStickY, rightStickX, rightStickY);
}
}
public void sendKeyboardInput(final short keyMap, final byte keyDirection, final byte modifier) {
if (!isMonkey) {
MoonBridge.sendKeyboardInput(keyMap, keyDirection, modifier);
}
}
public void sendMouseScroll(final byte scrollClicks) {
if (!isMonkey) {
MoonBridge.sendMouseScroll(scrollClicks);
}
}
public static String findExternalAddressForMdns(String stunHostname, int stunPort) {
return MoonBridge.findExternalAddressIP4(stunHostname, stunPort);
}
}

View File

@@ -0,0 +1,16 @@
package com.limelight.nvstream;
public interface NvConnectionListener {
void stageStarting(String stage);
void stageComplete(String stage);
void stageFailed(String stage, long errorCode);
void connectionStarted();
void connectionTerminated(long errorCode);
void connectionStatusUpdate(int connectionStatus);
void displayMessage(String message);
void displayTransientMessage(String message);
void rumble(short controllerNumber, short lowFreqMotor, short highFreqMotor);
}

View File

@@ -0,0 +1,233 @@
package com.limelight.nvstream;
import com.limelight.nvstream.http.NvApp;
import com.limelight.nvstream.jni.MoonBridge;
public class StreamConfiguration {
public static final int INVALID_APP_ID = 0;
public static final int STREAM_CFG_LOCAL = 0;
public static final int STREAM_CFG_REMOTE = 1;
public static final int STREAM_CFG_AUTO = 2;
private static final int CHANNEL_COUNT_STEREO = 2;
private static final int CHANNEL_COUNT_5_1 = 6;
private static final int CHANNEL_MASK_STEREO = 0x3;
private static final int CHANNEL_MASK_5_1 = 0xFC;
private NvApp app;
private int width, height;
private int refreshRate;
private int clientRefreshRateX100;
private int bitrate;
private boolean sops;
private boolean enableAdaptiveResolution;
private boolean playLocalAudio;
private int maxPacketSize;
private int remote;
private int audioChannelMask;
private int audioChannelCount;
private int audioConfiguration;
private boolean supportsHevc;
private int hevcBitratePercentageMultiplier;
private boolean enableHdr;
private int attachedGamepadMask;
public static class Builder {
private StreamConfiguration config = new StreamConfiguration();
public StreamConfiguration.Builder setApp(NvApp app) {
config.app = app;
return this;
}
public StreamConfiguration.Builder setRemoteConfiguration(int remote) {
config.remote = remote;
return this;
}
public StreamConfiguration.Builder setResolution(int width, int height) {
config.width = width;
config.height = height;
return this;
}
public StreamConfiguration.Builder setRefreshRate(int refreshRate) {
config.refreshRate = refreshRate;
return this;
}
public StreamConfiguration.Builder setBitrate(int bitrate) {
config.bitrate = bitrate;
return this;
}
public StreamConfiguration.Builder setEnableSops(boolean enable) {
config.sops = enable;
return this;
}
public StreamConfiguration.Builder enableAdaptiveResolution(boolean enable) {
config.enableAdaptiveResolution = enable;
return this;
}
public StreamConfiguration.Builder enableLocalAudioPlayback(boolean enable) {
config.playLocalAudio = enable;
return this;
}
public StreamConfiguration.Builder setMaxPacketSize(int maxPacketSize) {
config.maxPacketSize = maxPacketSize;
return this;
}
public StreamConfiguration.Builder setHevcBitratePercentageMultiplier(int multiplier) {
config.hevcBitratePercentageMultiplier = multiplier;
return this;
}
public StreamConfiguration.Builder setEnableHdr(boolean enableHdr) {
config.enableHdr = enableHdr;
return this;
}
public StreamConfiguration.Builder setAttachedGamepadMask(int attachedGamepadMask) {
config.attachedGamepadMask = attachedGamepadMask;
return this;
}
public StreamConfiguration.Builder setAttachedGamepadMaskByCount(int gamepadCount) {
config.attachedGamepadMask = 0;
for (int i = 0; i < 4; i++) {
if (gamepadCount > i) {
config.attachedGamepadMask |= 1 << i;
}
}
return this;
}
public StreamConfiguration.Builder setClientRefreshRateX100(int refreshRateX100) {
config.clientRefreshRateX100 = refreshRateX100;
return this;
}
public StreamConfiguration.Builder setAudioConfiguration(int audioConfig) {
if (audioConfig == MoonBridge.AUDIO_CONFIGURATION_STEREO) {
config.audioChannelCount = CHANNEL_COUNT_STEREO;
config.audioChannelMask = CHANNEL_MASK_STEREO;
}
else if (audioConfig == MoonBridge.AUDIO_CONFIGURATION_51_SURROUND) {
config.audioChannelCount = CHANNEL_COUNT_5_1;
config.audioChannelMask = CHANNEL_MASK_5_1;
}
else {
throw new IllegalArgumentException("Invalid audio configuration");
}
config.audioConfiguration = audioConfig;
return this;
}
public StreamConfiguration.Builder setHevcSupported(boolean supportsHevc) {
config.supportsHevc = supportsHevc;
return this;
}
public StreamConfiguration build() {
return config;
}
}
private StreamConfiguration() {
// Set default attributes
this.app = new NvApp("Steam");
this.width = 1280;
this.height = 720;
this.refreshRate = 60;
this.bitrate = 10000;
this.maxPacketSize = 1024;
this.remote = STREAM_CFG_AUTO;
this.sops = true;
this.enableAdaptiveResolution = false;
this.audioChannelCount = CHANNEL_COUNT_STEREO;
this.audioChannelMask = CHANNEL_MASK_STEREO;
this.supportsHevc = false;
this.enableHdr = false;
this.attachedGamepadMask = 0;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
public int getRefreshRate() {
return refreshRate;
}
public int getBitrate() {
return bitrate;
}
public int getMaxPacketSize() {
return maxPacketSize;
}
public NvApp getApp() {
return app;
}
public boolean getSops() {
return sops;
}
public boolean getAdaptiveResolutionEnabled() {
return enableAdaptiveResolution;
}
public boolean getPlayLocalAudio() {
return playLocalAudio;
}
public int getRemote() {
return remote;
}
public int getAudioChannelCount() {
return audioChannelCount;
}
public int getAudioChannelMask() {
return audioChannelMask;
}
public int getAudioConfiguration() {
return audioConfiguration;
}
public boolean getHevcSupported() {
return supportsHevc;
}
public int getHevcBitratePercentageMultiplier() {
return hevcBitratePercentageMultiplier;
}
public boolean getEnableHdr() {
return enableHdr;
}
public int getAttachedGamepadMask() {
return attachedGamepadMask;
}
public int getClientRefreshRateX100() {
return clientRefreshRateX100;
}
}

View File

@@ -0,0 +1,57 @@
package com.limelight.nvstream.av;
public class ByteBufferDescriptor {
public byte[] data;
public int offset;
public int length;
public ByteBufferDescriptor nextDescriptor;
public ByteBufferDescriptor(byte[] data, int offset, int length)
{
this.data = data;
this.offset = offset;
this.length = length;
}
public ByteBufferDescriptor(ByteBufferDescriptor desc)
{
this.data = desc.data;
this.offset = desc.offset;
this.length = desc.length;
}
public void reinitialize(byte[] data, int offset, int length)
{
this.data = data;
this.offset = offset;
this.length = length;
this.nextDescriptor = null;
}
public void print()
{
print(offset, length);
}
public void print(int length)
{
print(this.offset, length);
}
public void print(int offset, int length)
{
for (int i = offset; i < offset+length;) {
if (i + 8 <= offset+length) {
System.out.printf("%x: %02x %02x %02x %02x %02x %02x %02x %02x\n", i,
data[i], data[i+1], data[i+2], data[i+3], data[i+4], data[i+5], data[i+6], data[i+7]);
i += 8;
}
else {
System.out.printf("%x: %02x \n", i, data[i]);
i++;
}
}
System.out.println();
}
}

View File

@@ -0,0 +1,13 @@
package com.limelight.nvstream.av.audio;
public interface AudioRenderer {
int setup(int audioConfiguration);
void start();
void stop();
void playDecodedAudio(short[] audioData);
void cleanup();
}

View File

@@ -0,0 +1,18 @@
package com.limelight.nvstream.av.video;
public abstract class VideoDecoderRenderer {
public abstract int setup(int format, int width, int height, int redrawRate);
public abstract void start();
public abstract void stop();
// This is called once for each frame-start NALU. This means it will be called several times
// for an IDR frame which contains several parameter sets and the I-frame data.
public abstract int submitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType,
int frameNumber, long receiveTimeMs);
public abstract void cleanup();
public abstract int getCapabilities();
}

View File

@@ -0,0 +1,85 @@
package com.limelight.nvstream.http;
import java.security.cert.X509Certificate;
public class ComputerDetails {
public enum State {
ONLINE, OFFLINE, UNKNOWN
}
// Persistent attributes
public String uuid;
public String name;
public String localAddress;
public String remoteAddress;
public String manualAddress;
public String ipv6Address;
public String macAddress;
public X509Certificate serverCert;
// Transient attributes
public State state;
public String activeAddress;
public PairingManager.PairState pairState;
public int runningGameId;
public String rawAppList;
public ComputerDetails() {
// Use defaults
state = State.UNKNOWN;
}
public ComputerDetails(ComputerDetails details) {
// Copy details from the other computer
update(details);
}
public void update(ComputerDetails details) {
this.state = details.state;
this.name = details.name;
this.uuid = details.uuid;
if (details.activeAddress != null) {
this.activeAddress = details.activeAddress;
}
// We can get IPv4 loopback addresses with GS IPv6 Forwarder
if (details.localAddress != null && !details.localAddress.startsWith("127.")) {
this.localAddress = details.localAddress;
}
if (details.remoteAddress != null) {
this.remoteAddress = details.remoteAddress;
}
if (details.manualAddress != null) {
this.manualAddress = details.manualAddress;
}
if (details.ipv6Address != null) {
this.ipv6Address = details.ipv6Address;
}
if (details.macAddress != null && !details.macAddress.equals("00:00:00:00:00:00")) {
this.macAddress = details.macAddress;
}
if (details.serverCert != null) {
this.serverCert = details.serverCert;
}
this.pairState = details.pairState;
this.runningGameId = details.runningGameId;
this.rawAppList = details.rawAppList;
}
@Override
public String toString() {
StringBuilder str = new StringBuilder();
str.append("State: ").append(state).append("\n");
str.append("Active Address: ").append(activeAddress).append("\n");
str.append("Name: ").append(name).append("\n");
str.append("UUID: ").append(uuid).append("\n");
str.append("Local Address: ").append(localAddress).append("\n");
str.append("Remote Address: ").append(remoteAddress).append("\n");
str.append("IPv6 Address: ").append(ipv6Address).append("\n");
str.append("Manual Address: ").append(manualAddress).append("\n");
str.append("MAC Address: ").append(macAddress).append("\n");
str.append("Pair State: ").append(pairState).append("\n");
str.append("Running Game ID: ").append(runningGameId).append("\n");
return str.toString();
}
}

View File

@@ -0,0 +1,28 @@
package com.limelight.nvstream.http;
import java.io.IOException;
public class GfeHttpResponseException extends IOException {
private static final long serialVersionUID = 1543508830807804222L;
private int errorCode;
private String errorMsg;
public GfeHttpResponseException(int errorCode, String errorMsg) {
this.errorCode = errorCode;
this.errorMsg = errorMsg;
}
public int getErrorCode() {
return errorCode;
}
public String getErrorMessage() {
return errorMsg;
}
@Override
public String getMessage() {
return "GeForce Experience returned error: "+errorMsg+" (Error code: "+errorCode+")";
}
}

View File

@@ -0,0 +1,11 @@
package com.limelight.nvstream.http;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPrivateKey;
public interface LimelightCryptoProvider {
X509Certificate getClientCertificate();
RSAPrivateKey getClientPrivateKey();
byte[] getPemEncodedClientCertificate();
String encodeBase64String(byte[] data);
}

View File

@@ -0,0 +1,61 @@
package com.limelight.nvstream.http;
import com.limelight.LimeLog;
public class NvApp {
private String appName = "";
private int appId;
private boolean initialized;
private boolean hdrSupported;
public NvApp() {}
public NvApp(String appName) {
this.appName = appName;
}
public NvApp(String appName, int appId, boolean hdrSupported) {
this.appName = appName;
this.appId = appId;
this.hdrSupported = hdrSupported;
this.initialized = true;
}
public void setAppName(String appName) {
this.appName = appName;
}
public void setAppId(String appId) {
try {
this.appId = Integer.parseInt(appId);
this.initialized = true;
} catch (NumberFormatException e) {
LimeLog.warning("Malformed app ID: "+appId);
}
}
public void setAppId(int appId) {
this.appId = appId;
this.initialized = true;
}
public void setHdrSupported(boolean hdrSupported) {
this.hdrSupported = hdrSupported;
}
public String getAppName() {
return this.appName;
}
public int getAppId() {
return this.appId;
}
public boolean isHdrSupported() {
return this.hdrSupported;
}
public boolean isInitialized() {
return this.initialized;
}
}

View File

@@ -0,0 +1,686 @@
package com.limelight.nvstream.http;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.StringReader;
import java.net.MalformedURLException;
import java.net.Socket;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.Principal;
import java.security.PrivateKey;
import java.security.SecureRandom;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.LinkedList;
import java.util.ListIterator;
import java.util.Stack;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.KeyManager;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLSession;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509KeyManager;
import javax.net.ssl.X509TrustManager;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlPullParserFactory;
import com.limelight.BuildConfig;
import com.limelight.LimeLog;
import com.limelight.nvstream.ConnectionContext;
import com.limelight.nvstream.http.PairingManager.PairState;
import okhttp3.ConnectionPool;
import okhttp3.Handshake;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
public class NvHTTP {
private String uniqueId;
private PairingManager pm;
public static final int HTTPS_PORT = 47984;
public static final int HTTP_PORT = 47989;
public static final int CONNECTION_TIMEOUT = 3000;
public static final int READ_TIMEOUT = 5000;
// Print URL and content to logcat on debug builds
private static boolean verbose = BuildConfig.DEBUG;
public String baseUrlHttps;
public String baseUrlHttp;
private OkHttpClient httpClient;
private OkHttpClient httpClientWithReadTimeout;
private X509TrustManager trustManager;
private X509KeyManager keyManager;
private X509Certificate serverCert;
void setServerCert(X509Certificate serverCert) {
this.serverCert = serverCert;
trustManager = new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
public void checkClientTrusted(X509Certificate[] certs, String authType) {
throw new IllegalStateException("Should never be called");
}
public void checkServerTrusted(X509Certificate[] certs, String authType) throws CertificateException {
// Check the server certificate if we've paired to this host
if (!certs[0].equals(NvHTTP.this.serverCert)) {
throw new CertificateException("Certificate mismatch");
}
}
};
}
private void initializeHttpState(final X509Certificate serverCert, final LimelightCryptoProvider cryptoProvider) {
// Set up TrustManager
setServerCert(serverCert);
keyManager = new X509KeyManager() {
public String chooseClientAlias(String[] keyTypes,
Principal[] issuers, Socket socket) { return "Limelight-RSA"; }
public String chooseServerAlias(String keyType, Principal[] issuers,
Socket socket) { return null; }
public X509Certificate[] getCertificateChain(String alias) {
return new X509Certificate[] {cryptoProvider.getClientCertificate()};
}
public String[] getClientAliases(String keyType, Principal[] issuers) { return null; }
public PrivateKey getPrivateKey(String alias) {
return cryptoProvider.getClientPrivateKey();
}
public String[] getServerAliases(String keyType, Principal[] issuers) { return null; }
};
// Ignore differences between given hostname and certificate hostname
HostnameVerifier hv = new HostnameVerifier() {
public boolean verify(String hostname, SSLSession session) { return true; }
};
httpClient = new OkHttpClient.Builder()
.connectionPool(new ConnectionPool(0, 1, TimeUnit.MILLISECONDS))
.hostnameVerifier(hv)
.readTimeout(0, TimeUnit.MILLISECONDS)
.connectTimeout(CONNECTION_TIMEOUT, TimeUnit.MILLISECONDS)
.build();
httpClientWithReadTimeout = httpClient.newBuilder()
.readTimeout(READ_TIMEOUT, TimeUnit.MILLISECONDS)
.build();
}
public NvHTTP(String address, String uniqueId, X509Certificate serverCert, LimelightCryptoProvider cryptoProvider) throws IOException {
// Use the same UID for all Moonlight clients so we can quit games
// started by other Moonlight clients.
this.uniqueId = "0123456789ABCDEF";
initializeHttpState(serverCert, cryptoProvider);
try {
// The URI constructor takes care of escaping IPv6 literals
this.baseUrlHttps = new URI("https", null, address, HTTPS_PORT, null, null, null).toString();
this.baseUrlHttp = new URI("http", null, address, HTTP_PORT, null, null, null).toString();
} catch (URISyntaxException e) {
// Encapsulate URISyntaxException into IOException for callers to handle more easily
throw new IOException(e);
}
this.pm = new PairingManager(this, cryptoProvider);
}
String buildUniqueIdUuidString() {
return "uniqueid="+uniqueId+"&uuid="+UUID.randomUUID();
}
static String getXmlString(Reader r, String tagname) throws XmlPullParserException, IOException {
XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
factory.setNamespaceAware(true);
XmlPullParser xpp = factory.newPullParser();
xpp.setInput(r);
int eventType = xpp.getEventType();
Stack<String> currentTag = new Stack<String>();
while (eventType != XmlPullParser.END_DOCUMENT) {
switch (eventType) {
case (XmlPullParser.START_TAG):
if (xpp.getName().equals("root")) {
verifyResponseStatus(xpp);
}
currentTag.push(xpp.getName());
break;
case (XmlPullParser.END_TAG):
currentTag.pop();
break;
case (XmlPullParser.TEXT):
if (currentTag.peek().equals(tagname)) {
return xpp.getText().trim();
}
break;
}
eventType = xpp.next();
}
return null;
}
static String getXmlString(String str, String tagname) throws XmlPullParserException, IOException {
return getXmlString(new StringReader(str), tagname);
}
private static void verifyResponseStatus(XmlPullParser xpp) throws GfeHttpResponseException {
int statusCode = Integer.parseInt(xpp.getAttributeValue(XmlPullParser.NO_NAMESPACE, "status_code"));
if (statusCode != 200) {
throw new GfeHttpResponseException(statusCode, xpp.getAttributeValue(XmlPullParser.NO_NAMESPACE, "status_message"));
}
}
public String getServerInfo() throws IOException, XmlPullParserException {
String resp;
//
// TODO: Shield Hub uses HTTP for this and is able to get an accurate PairStatus with HTTP.
// For some reason, we always see PairStatus is 0 over HTTP and only 1 over HTTPS. It looks
// like there are extra request headers required to make this stuff work over HTTP.
//
// When we have a pinned cert, use HTTPS to fetch serverinfo and fall back on cert mismatch
if (serverCert != null) {
try {
try {
resp = openHttpConnectionToString(baseUrlHttps + "/serverinfo?"+buildUniqueIdUuidString(), true);
} catch (SSLHandshakeException e) {
// Detect if we failed due to a server cert mismatch
if (e.getCause() instanceof CertificateException) {
// Jump to the GfeHttpResponseException exception handler to retry
// over HTTP which will allow us to pair again to update the cert
throw new GfeHttpResponseException(401, "Server certificate mismatch");
}
else {
throw e;
}
}
// This will throw an exception if the request came back with a failure status.
// We want this because it will throw us into the HTTP case if the client is unpaired.
getServerVersion(resp);
}
catch (GfeHttpResponseException e) {
if (e.getErrorCode() == 401) {
// Cert validation error - fall back to HTTP
return openHttpConnectionToString(baseUrlHttp + "/serverinfo", true);
}
// If it's not a cert validation error, throw it
throw e;
}
return resp;
}
else {
// No pinned cert, so use HTTP
return openHttpConnectionToString(baseUrlHttp + "/serverinfo", true);
}
}
public ComputerDetails getComputerDetails() throws IOException, XmlPullParserException {
ComputerDetails details = new ComputerDetails();
String serverInfo = getServerInfo();
details.name = getXmlString(serverInfo, "hostname");
if (details.name == null || details.name.isEmpty()) {
details.name = "UNKNOWN";
}
details.uuid = getXmlString(serverInfo, "uniqueid");
details.macAddress = getXmlString(serverInfo, "mac");
details.localAddress = getXmlString(serverInfo, "LocalIP");
// This may be null, but that's okay
details.remoteAddress = getXmlString(serverInfo, "ExternalIP");
// This has some extra logic to always report unpaired if the pinned cert isn't there
details.pairState = getPairState(serverInfo);
try {
details.runningGameId = getCurrentGame(serverInfo);
} catch (NumberFormatException e) {
details.runningGameId = 0;
}
// We could reach it so it's online
details.state = ComputerDetails.State.ONLINE;
return details;
}
// This hack is Android-specific but we do it on all platforms
// because it doesn't really matter
private OkHttpClient performAndroidTlsHack(OkHttpClient client) {
// Doing this each time we create a socket is required
// to avoid the SSLv3 fallback that causes connection failures
try {
SSLContext sc = SSLContext.getInstance("TLS");
sc.init(new KeyManager[] { keyManager }, new TrustManager[] { trustManager }, new SecureRandom());
return client.newBuilder().sslSocketFactory(sc.getSocketFactory(), trustManager).build();
} catch (NoSuchAlgorithmException | KeyManagementException e) {
throw new RuntimeException(e);
}
}
public X509Certificate getCertificateIfTrusted() {
try {
Response resp = httpClient.newCall(new Request.Builder().url(baseUrlHttps).get().build()).execute();
Handshake handshake = resp.handshake();
if (handshake != null) {
return (X509Certificate)handshake.peerCertificates().get(0);
}
} catch (IOException ignored) {}
return null;
}
// Read timeout should be enabled for any HTTP query that requires no outside action
// on the GFE server. Examples of queries that DO require outside action are launch, resume, and quit.
// The initial pair query does require outside action (user entering a PIN) but subsequent pairing
// queries do not.
private ResponseBody openHttpConnection(String url, boolean enableReadTimeout) throws IOException {
Request request = new Request.Builder().url(url).get().build();
Response response;
if (serverCert == null && !url.startsWith(baseUrlHttp)) {
throw new IllegalStateException("Attempted HTTPS fetch without pinned cert");
}
if (enableReadTimeout) {
response = performAndroidTlsHack(httpClientWithReadTimeout).newCall(request).execute();
}
else {
response = performAndroidTlsHack(httpClient).newCall(request).execute();
}
ResponseBody body = response.body();
if (response.isSuccessful()) {
return body;
}
// Unsuccessful, so close the response body
if (body != null) {
body.close();
}
if (response.code() == 404) {
throw new FileNotFoundException(url);
}
else {
throw new IOException("HTTP request failed: "+response.code());
}
}
String openHttpConnectionToString(String url, boolean enableReadTimeout) throws IOException {
try {
if (verbose) {
LimeLog.info("Requesting URL: "+url);
}
ResponseBody resp = openHttpConnection(url, enableReadTimeout);
String respString = resp.string();
resp.close();
if (verbose) {
LimeLog.info(url+" -> "+respString);
}
return respString;
} catch (IOException e) {
if (verbose) {
e.printStackTrace();
}
throw e;
}
}
public String getServerVersion(String serverInfo) throws XmlPullParserException, IOException {
return getXmlString(serverInfo, "appversion");
}
public PairingManager.PairState getPairState() throws IOException, XmlPullParserException {
return getPairState(getServerInfo());
}
public PairingManager.PairState getPairState(String serverInfo) throws IOException, XmlPullParserException {
// If we don't have a server cert, we can't be paired even if the host thinks we are
if (serverCert == null) {
return PairState.NOT_PAIRED;
}
if (!NvHTTP.getXmlString(serverInfo, "PairStatus").equals("1")) {
return PairState.NOT_PAIRED;
}
return PairState.PAIRED;
}
public long getMaxLumaPixelsH264(String serverInfo) throws XmlPullParserException, IOException {
String str = getXmlString(serverInfo, "MaxLumaPixelsH264");
if (str != null) {
try {
return Long.parseLong(str);
} catch (NumberFormatException e) {
return 0;
}
} else {
return 0;
}
}
public long getMaxLumaPixelsHEVC(String serverInfo) throws XmlPullParserException, IOException {
String str = getXmlString(serverInfo, "MaxLumaPixelsHEVC");
if (str != null) {
try {
return Long.parseLong(str);
} catch (NumberFormatException e) {
return 0;
}
} else {
return 0;
}
}
// Possible meaning of bits
// Bit 0: H.264 Baseline
// Bit 1: H.264 High
// ----
// Bit 8: HEVC Main
// Bit 9: HEVC Main10
// Bit 10: HEVC Main10 4:4:4
// Bit 11: ???
public long getServerCodecModeSupport(String serverInfo) throws XmlPullParserException, IOException {
String str = getXmlString(serverInfo, "ServerCodecModeSupport");
if (str != null) {
try {
return Long.parseLong(str);
} catch (NumberFormatException e) {
return 0;
}
} else {
return 0;
}
}
public String getGpuType(String serverInfo) throws XmlPullParserException, IOException {
return getXmlString(serverInfo, "gputype");
}
public String getGfeVersion(String serverInfo) throws XmlPullParserException, IOException {
return getXmlString(serverInfo, "GfeVersion");
}
public boolean supports4K(String serverInfo) throws XmlPullParserException, IOException {
// Only allow 4K on GFE 3.x
String gfeVersionStr = getXmlString(serverInfo, "GfeVersion");
if (gfeVersionStr == null || gfeVersionStr.startsWith("2.")) {
return false;
}
return true;
}
public int getCurrentGame(String serverInfo) throws IOException, XmlPullParserException {
// GFE 2.8 started keeping currentgame set to the last game played. As a result, it no longer
// has the semantics that its name would indicate. To contain the effects of this change as much
// as possible, we'll force the current game to zero if the server isn't in a streaming session.
String serverState = getXmlString(serverInfo, "state");
if (serverState != null && serverState.endsWith("_SERVER_BUSY")) {
String game = getXmlString(serverInfo, "currentgame");
return Integer.parseInt(game);
}
else {
return 0;
}
}
public NvApp getAppById(int appId) throws IOException, XmlPullParserException {
LinkedList<NvApp> appList = getAppList();
for (NvApp appFromList : appList) {
if (appFromList.getAppId() == appId) {
return appFromList;
}
}
return null;
}
/* NOTE: Only use this function if you know what you're doing.
* It's totally valid to have two apps named the same thing,
* or even nothing at all! Look apps up by ID if at all possible
* using the above function */
public NvApp getAppByName(String appName) throws IOException, XmlPullParserException {
LinkedList<NvApp> appList = getAppList();
for (NvApp appFromList : appList) {
if (appFromList.getAppName().equalsIgnoreCase(appName)) {
return appFromList;
}
}
return null;
}
public PairingManager getPairingManager() {
return pm;
}
public static LinkedList<NvApp> getAppListByReader(Reader r) throws XmlPullParserException, IOException {
XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
factory.setNamespaceAware(true);
XmlPullParser xpp = factory.newPullParser();
xpp.setInput(r);
int eventType = xpp.getEventType();
LinkedList<NvApp> appList = new LinkedList<NvApp>();
Stack<String> currentTag = new Stack<String>();
boolean rootTerminated = false;
while (eventType != XmlPullParser.END_DOCUMENT) {
switch (eventType) {
case (XmlPullParser.START_TAG):
if (xpp.getName().equals("root")) {
verifyResponseStatus(xpp);
}
currentTag.push(xpp.getName());
if (xpp.getName().equals("App")) {
appList.addLast(new NvApp());
}
break;
case (XmlPullParser.END_TAG):
currentTag.pop();
if (xpp.getName().equals("root")) {
rootTerminated = true;
}
break;
case (XmlPullParser.TEXT):
NvApp app = appList.getLast();
if (currentTag.peek().equals("AppTitle")) {
app.setAppName(xpp.getText().trim());
} else if (currentTag.peek().equals("ID")) {
app.setAppId(xpp.getText().trim());
} else if (currentTag.peek().equals("IsHdrSupported")) {
app.setHdrSupported(xpp.getText().trim().equals("1"));
}
break;
}
eventType = xpp.next();
}
// Throw a malformed XML exception if we've not seen the root tag ended
if (!rootTerminated) {
throw new XmlPullParserException("Malformed XML: Root tag was not terminated");
}
// Ensure that all apps in the list are initialized
ListIterator<NvApp> i = appList.listIterator();
while (i.hasNext()) {
NvApp app = i.next();
// Remove uninitialized apps
if (!app.isInitialized()) {
LimeLog.warning("GFE returned incomplete app: "+app.getAppId()+" "+app.getAppName());
i.remove();
}
}
return appList;
}
public String getAppListRaw() throws MalformedURLException, IOException {
return openHttpConnectionToString(baseUrlHttps + "/applist?"+buildUniqueIdUuidString(), true);
}
public LinkedList<NvApp> getAppList() throws GfeHttpResponseException, IOException, XmlPullParserException {
if (verbose) {
// Use the raw function so the app list is printed
return getAppListByReader(new StringReader(getAppListRaw()));
}
else {
ResponseBody resp = openHttpConnection(baseUrlHttps + "/applist?" + buildUniqueIdUuidString(), true);
LinkedList<NvApp> appList = getAppListByReader(new InputStreamReader(resp.byteStream()));
resp.close();
return appList;
}
}
public void unpair() throws IOException {
openHttpConnectionToString(baseUrlHttp + "/unpair?"+buildUniqueIdUuidString(), true);
}
public InputStream getBoxArt(NvApp app) throws IOException {
ResponseBody resp = openHttpConnection(baseUrlHttps + "/appasset?"+ buildUniqueIdUuidString() +
"&appid=" + app.getAppId() + "&AssetType=2&AssetIdx=0", true);
return resp.byteStream();
}
public int getServerMajorVersion(String serverInfo) throws XmlPullParserException, IOException {
int[] appVersionQuad = getServerAppVersionQuad(serverInfo);
if (appVersionQuad != null) {
return appVersionQuad[0];
}
else {
return 0;
}
}
public int[] getServerAppVersionQuad(String serverInfo) throws XmlPullParserException, IOException {
try {
String serverVersion = getServerVersion(serverInfo);
if (serverVersion == null) {
LimeLog.warning("Missing server version field");
return null;
}
String[] serverVersionSplit = serverVersion.split("\\.");
if (serverVersionSplit.length != 4) {
LimeLog.warning("Malformed server version field");
return null;
}
int[] ret = new int[serverVersionSplit.length];
for (int i = 0; i < ret.length; i++) {
ret[i] = Integer.parseInt(serverVersionSplit[i]);
}
return ret;
} catch (NumberFormatException e) {
e.printStackTrace();
return null;
}
}
final private static char[] hexArray = "0123456789ABCDEF".toCharArray();
private static String bytesToHex(byte[] bytes) {
char[] hexChars = new char[bytes.length * 2];
for ( int j = 0; j < bytes.length; j++ ) {
int v = bytes[j] & 0xFF;
hexChars[j * 2] = hexArray[v >>> 4];
hexChars[j * 2 + 1] = hexArray[v & 0x0F];
}
return new String(hexChars);
}
public boolean launchApp(ConnectionContext context, int appId, boolean enableHdr) throws IOException, XmlPullParserException {
// Using an FPS value over 60 causes SOPS to default to 720p60,
// so force it to 60 when starting. This won't impact our ability
// to get > 60 FPS while actually streaming though.
int fps = context.negotiatedFps > 60 ? 60 : context.negotiatedFps;
// Using an unsupported resolution (not 720p, 1080p, or 4K) causes
// GFE to force SOPS to 720p60. This is fine for < 720p resolutions like
// 360p or 480p, but it is not ideal for 1440p and other resolutions.
// When we detect an unsupported resolution, disable SOPS unless it's under 720p.
// FIXME: Detect support resolutions using the serverinfo response, not a hardcoded list
boolean enableSops = context.streamConfig.getSops();
if (context.negotiatedWidth * context.negotiatedHeight > 1280 * 720 &&
context.negotiatedWidth * context.negotiatedHeight != 1920 * 1080 &&
context.negotiatedWidth * context.negotiatedHeight != 3840 * 2160) {
LimeLog.info("Disabling SOPS due to non-standard resolution: "+context.negotiatedWidth+"x"+context.negotiatedHeight);
enableSops = false;
}
String xmlStr = openHttpConnectionToString(baseUrlHttps +
"/launch?" + buildUniqueIdUuidString() +
"&appid=" + appId +
"&mode=" + context.negotiatedWidth + "x" + context.negotiatedHeight + "x" + fps +
"&additionalStates=1&sops=" + (enableSops ? 1 : 0) +
"&rikey="+bytesToHex(context.riKey.getEncoded()) +
"&rikeyid="+context.riKeyId +
(!enableHdr ? "" : "&hdrMode=1&clientHdrCapVersion=0&clientHdrCapSupportedFlagsInUint32=0&clientHdrCapMetaDataId=NV_STATIC_METADATA_TYPE_1&clientHdrCapDisplayData=0x0x0x0x0x0x0x0x0x0x0") +
"&localAudioPlayMode=" + (context.streamConfig.getPlayLocalAudio() ? 1 : 0) +
"&surroundAudioInfo=" + ((context.streamConfig.getAudioChannelMask() << 16) + context.streamConfig.getAudioChannelCount()) +
(context.streamConfig.getAttachedGamepadMask() != 0 ? "&remoteControllersBitmap=" + context.streamConfig.getAttachedGamepadMask() : "") +
(context.streamConfig.getAttachedGamepadMask() != 0 ? "&gcmap=" + context.streamConfig.getAttachedGamepadMask() : ""),
false);
String gameSession = getXmlString(xmlStr, "gamesession");
return gameSession != null && !gameSession.equals("0");
}
public boolean resumeApp(ConnectionContext context) throws IOException, XmlPullParserException {
String xmlStr = openHttpConnectionToString(baseUrlHttps + "/resume?" + buildUniqueIdUuidString() +
"&rikey="+bytesToHex(context.riKey.getEncoded()) +
"&rikeyid="+context.riKeyId +
"&surroundAudioInfo=" + ((context.streamConfig.getAudioChannelMask() << 16) + context.streamConfig.getAudioChannelCount()),
false);
String resume = getXmlString(xmlStr, "resume");
return Integer.parseInt(resume) != 0;
}
public boolean quitApp() throws IOException, XmlPullParserException {
String xmlStr = openHttpConnectionToString(baseUrlHttps + "/cancel?" + buildUniqueIdUuidString(), false);
String cancel = getXmlString(xmlStr, "cancel");
if (Integer.parseInt(cancel) == 0) {
return false;
}
// Newer GFE versions will just return success even if quitting fails
// if we're not the original requestor.
if (getCurrentGame(getServerInfo()) != 0) {
// Generate a synthetic GfeResponseException letting the caller know
// that they can't kill someone else's stream.
throw new GfeHttpResponseException(599, "");
}
return true;
}
}

View File

@@ -0,0 +1,340 @@
package com.limelight.nvstream.http;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.xmlpull.v1.XmlPullParserException;
import com.limelight.LimeLog;
import java.security.cert.Certificate;
import java.io.*;
import java.security.*;
import java.security.cert.*;
import java.util.Arrays;
import java.util.Locale;
import java.util.Random;
public class PairingManager {
private NvHTTP http;
private PrivateKey pk;
private X509Certificate cert;
private SecretKey aesKey;
private byte[] pemCertBytes;
private X509Certificate serverCert;
public enum PairState {
NOT_PAIRED,
PAIRED,
PIN_WRONG,
FAILED,
ALREADY_IN_PROGRESS
}
public PairingManager(NvHTTP http, LimelightCryptoProvider cryptoProvider) {
this.http = http;
this.cert = cryptoProvider.getClientCertificate();
this.pemCertBytes = cryptoProvider.getPemEncodedClientCertificate();
this.pk = cryptoProvider.getClientPrivateKey();
}
final private static char[] hexArray = "0123456789ABCDEF".toCharArray();
private static String bytesToHex(byte[] bytes) {
char[] hexChars = new char[bytes.length * 2];
for ( int j = 0; j < bytes.length; j++ ) {
int v = bytes[j] & 0xFF;
hexChars[j * 2] = hexArray[v >>> 4];
hexChars[j * 2 + 1] = hexArray[v & 0x0F];
}
return new String(hexChars);
}
private static byte[] hexToBytes(String s) {
int len = s.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)
+ Character.digit(s.charAt(i+1), 16));
}
return data;
}
private X509Certificate extractPlainCert(String text) throws XmlPullParserException, IOException
{
String certText = NvHTTP.getXmlString(text, "plaincert");
if (certText != null) {
byte[] certBytes = hexToBytes(certText);
try {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
return (X509Certificate)cf.generateCertificate(new ByteArrayInputStream(certBytes));
} catch (CertificateException e) {
e.printStackTrace();
return null;
}
}
else {
return null;
}
}
private byte[] generateRandomBytes(int length)
{
byte[] rand = new byte[length];
new SecureRandom().nextBytes(rand);
return rand;
}
private static byte[] saltPin(byte[] salt, String pin) throws UnsupportedEncodingException {
byte[] saltedPin = new byte[salt.length + pin.length()];
System.arraycopy(salt, 0, saltedPin, 0, salt.length);
System.arraycopy(pin.getBytes("UTF-8"), 0, saltedPin, salt.length, pin.length());
return saltedPin;
}
private static boolean verifySignature(byte[] data, byte[] signature, Certificate cert) {
try {
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initVerify(cert.getPublicKey());
sig.update(data);
return sig.verify(signature);
} catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException e) {
e.printStackTrace();
throw new RuntimeException(e);
}
}
private static byte[] signData(byte[] data, PrivateKey key) {
try {
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initSign(key);
sig.update(data);
byte[] signature = new byte[256];
sig.sign(signature, 0, signature.length);
return signature;
} catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException e) {
e.printStackTrace();
throw new RuntimeException(e);
}
}
private static byte[] decryptAes(byte[] encryptedData, SecretKey secretKey) {
try {
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
int blockRoundedSize = ((encryptedData.length + 15) / 16) * 16;
byte[] blockRoundedEncrypted = Arrays.copyOf(encryptedData, blockRoundedSize);
byte[] fullDecrypted = new byte[blockRoundedSize];
cipher.init(Cipher.DECRYPT_MODE, secretKey);
cipher.doFinal(blockRoundedEncrypted, 0,
blockRoundedSize, fullDecrypted);
return fullDecrypted;
} catch (GeneralSecurityException e) {
e.printStackTrace();
throw new RuntimeException(e);
}
}
private static byte[] encryptAes(byte[] data, SecretKey secretKey) {
try {
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
int blockRoundedSize = ((data.length + 15) / 16) * 16;
byte[] blockRoundedData = Arrays.copyOf(data, blockRoundedSize);
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
return cipher.doFinal(blockRoundedData);
} catch (GeneralSecurityException e) {
e.printStackTrace();
throw new RuntimeException(e);
}
}
private static SecretKey generateAesKey(PairingHashAlgorithm hashAlgo, byte[] keyData) {
byte[] aesTruncated = Arrays.copyOf(hashAlgo.hashData(keyData), 16);
return new SecretKeySpec(aesTruncated, "AES");
}
private static byte[] concatBytes(byte[] a, byte[] b) {
byte[] c = new byte[a.length + b.length];
System.arraycopy(a, 0, c, 0, a.length);
System.arraycopy(b, 0, c, a.length, b.length);
return c;
}
public static String generatePinString() {
Random r = new Random();
return String.format((Locale)null, "%d%d%d%d",
r.nextInt(10), r.nextInt(10),
r.nextInt(10), r.nextInt(10));
}
public X509Certificate getPairedCert() {
return serverCert;
}
public PairState pair(String serverInfo, String pin) throws IOException, XmlPullParserException {
PairingHashAlgorithm hashAlgo;
int serverMajorVersion = http.getServerMajorVersion(serverInfo);
LimeLog.info("Pairing with server generation: "+serverMajorVersion);
if (serverMajorVersion >= 7) {
// Gen 7+ uses SHA-256 hashing
hashAlgo = new Sha256PairingHash();
}
else {
// Prior to Gen 7, SHA-1 is used
hashAlgo = new Sha1PairingHash();
}
// Generate a salt for hashing the PIN
byte[] salt = generateRandomBytes(16);
// Combine the salt and pin, then create an AES key from them
byte[] saltAndPin = saltPin(salt, pin);
aesKey = generateAesKey(hashAlgo, saltAndPin);
// Send the salt and get the server cert. This doesn't have a read timeout
// because the user must enter the PIN before the server responds
String getCert = http.openHttpConnectionToString(http.baseUrlHttp +
"/pair?"+http.buildUniqueIdUuidString()+"&devicename=roth&updateState=1&phrase=getservercert&salt="+
bytesToHex(salt)+"&clientcert="+bytesToHex(pemCertBytes),
false);
if (!NvHTTP.getXmlString(getCert, "paired").equals("1")) {
return PairState.FAILED;
}
// Save this cert for retrieval later
serverCert = extractPlainCert(getCert);
if (serverCert == null) {
// Attempting to pair while another device is pairing will cause GFE
// to give an empty cert in the response.
return PairState.ALREADY_IN_PROGRESS;
}
// Require this cert for TLS to this host
http.setServerCert(serverCert);
// Generate a random challenge and encrypt it with our AES key
byte[] randomChallenge = generateRandomBytes(16);
byte[] encryptedChallenge = encryptAes(randomChallenge, aesKey);
// Send the encrypted challenge to the server
String challengeResp = http.openHttpConnectionToString(http.baseUrlHttp +
"/pair?"+http.buildUniqueIdUuidString()+"&devicename=roth&updateState=1&clientchallenge="+bytesToHex(encryptedChallenge),
true);
if (!NvHTTP.getXmlString(challengeResp, "paired").equals("1")) {
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
return PairState.FAILED;
}
// Decode the server's response and subsequent challenge
byte[] encServerChallengeResponse = hexToBytes(NvHTTP.getXmlString(challengeResp, "challengeresponse"));
byte[] decServerChallengeResponse = decryptAes(encServerChallengeResponse, aesKey);
byte[] serverResponse = Arrays.copyOfRange(decServerChallengeResponse, 0, hashAlgo.getHashLength());
byte[] serverChallenge = Arrays.copyOfRange(decServerChallengeResponse, hashAlgo.getHashLength(), hashAlgo.getHashLength() + 16);
// Using another 16 bytes secret, compute a challenge response hash using the secret, our cert sig, and the challenge
byte[] clientSecret = generateRandomBytes(16);
byte[] challengeRespHash = hashAlgo.hashData(concatBytes(concatBytes(serverChallenge, cert.getSignature()), clientSecret));
byte[] challengeRespEncrypted = encryptAes(challengeRespHash, aesKey);
String secretResp = http.openHttpConnectionToString(http.baseUrlHttp +
"/pair?"+http.buildUniqueIdUuidString()+"&devicename=roth&updateState=1&serverchallengeresp="+bytesToHex(challengeRespEncrypted),
true);
if (!NvHTTP.getXmlString(secretResp, "paired").equals("1")) {
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
return PairState.FAILED;
}
// Get the server's signed secret
byte[] serverSecretResp = hexToBytes(NvHTTP.getXmlString(secretResp, "pairingsecret"));
byte[] serverSecret = Arrays.copyOfRange(serverSecretResp, 0, 16);
byte[] serverSignature = Arrays.copyOfRange(serverSecretResp, 16, 272);
// Ensure the authenticity of the data
if (!verifySignature(serverSecret, serverSignature, serverCert)) {
// Cancel the pairing process
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
// Looks like a MITM
return PairState.FAILED;
}
// Ensure the server challenge matched what we expected (aka the PIN was correct)
byte[] serverChallengeRespHash = hashAlgo.hashData(concatBytes(concatBytes(randomChallenge, serverCert.getSignature()), serverSecret));
if (!Arrays.equals(serverChallengeRespHash, serverResponse)) {
// Cancel the pairing process
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
// Probably got the wrong PIN
return PairState.PIN_WRONG;
}
// Send the server our signed secret
byte[] clientPairingSecret = concatBytes(clientSecret, signData(clientSecret, pk));
String clientSecretResp = http.openHttpConnectionToString(http.baseUrlHttp +
"/pair?"+http.buildUniqueIdUuidString()+"&devicename=roth&updateState=1&clientpairingsecret="+bytesToHex(clientPairingSecret),
true);
if (!NvHTTP.getXmlString(clientSecretResp, "paired").equals("1")) {
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
return PairState.FAILED;
}
// Do the initial challenge (seems neccessary for us to show as paired)
String pairChallenge = http.openHttpConnectionToString(http.baseUrlHttps +
"/pair?"+http.buildUniqueIdUuidString()+"&devicename=roth&updateState=1&phrase=pairchallenge", true);
if (!NvHTTP.getXmlString(pairChallenge, "paired").equals("1")) {
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
return PairState.FAILED;
}
return PairState.PAIRED;
}
private interface PairingHashAlgorithm {
int getHashLength();
byte[] hashData(byte[] data);
}
private static class Sha1PairingHash implements PairingHashAlgorithm {
public int getHashLength() {
return 20;
}
public byte[] hashData(byte[] data) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-1");
return md.digest(data);
}
catch (NoSuchAlgorithmException e) {
// Shouldn't ever happen
e.printStackTrace();
return null;
}
}
}
private static class Sha256PairingHash implements PairingHashAlgorithm {
public int getHashLength() {
return 32;
}
public byte[] hashData(byte[] data) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return md.digest(data);
}
catch (NoSuchAlgorithmException e) {
// Shouldn't ever happen
e.printStackTrace();
return null;
}
}
}
}

View File

@@ -0,0 +1,19 @@
package com.limelight.nvstream.input;
public class ControllerPacket {
public static final short A_FLAG = 0x1000;
public static final short B_FLAG = 0x2000;
public static final short X_FLAG = 0x4000;
public static final short Y_FLAG = (short)0x8000;
public static final short UP_FLAG = 0x0001;
public static final short DOWN_FLAG = 0x0002;
public static final short LEFT_FLAG = 0x0004;
public static final short RIGHT_FLAG = 0x0008;
public static final short LB_FLAG = 0x0100;
public static final short RB_FLAG = 0x0200;
public static final short PLAY_FLAG = 0x0010;
public static final short BACK_FLAG = 0x0020;
public static final short LS_CLK_FLAG = 0x0040;
public static final short RS_CLK_FLAG = 0x0080;
public static final short SPECIAL_BUTTON_FLAG = 0x0400;
}

View File

@@ -0,0 +1,10 @@
package com.limelight.nvstream.input;
public class KeyboardPacket {
public static final byte KEY_DOWN = 0x03;
public static final byte KEY_UP = 0x04;
public static final byte MODIFIER_SHIFT = 0x01;
public static final byte MODIFIER_CTRL = 0x02;
public static final byte MODIFIER_ALT = 0x04;
}

View File

@@ -0,0 +1,12 @@
package com.limelight.nvstream.input;
public class MouseButtonPacket {
public static final byte PRESS_EVENT = 0x07;
public static final byte RELEASE_EVENT = 0x08;
public static final byte BUTTON_LEFT = 0x01;
public static final byte BUTTON_MIDDLE = 0x02;
public static final byte BUTTON_RIGHT = 0x03;
public static final byte BUTTON_X1 = 0x04;
public static final byte BUTTON_X2 = 0x05;
}

View File

@@ -0,0 +1,216 @@
package com.limelight.nvstream.jni;
import com.limelight.nvstream.NvConnectionListener;
import com.limelight.nvstream.av.audio.AudioRenderer;
import com.limelight.nvstream.av.video.VideoDecoderRenderer;
public class MoonBridge {
/* See documentation in Limelight.h for information about these functions and constants */
public static final int AUDIO_CONFIGURATION_STEREO = 0;
public static final int AUDIO_CONFIGURATION_51_SURROUND = 1;
public static final int VIDEO_FORMAT_H264 = 0x0001;
public static final int VIDEO_FORMAT_H265 = 0x0100;
public static final int VIDEO_FORMAT_H265_MAIN10 = 0x0200;
public static final int VIDEO_FORMAT_MASK_H264 = 0x00FF;
public static final int VIDEO_FORMAT_MASK_H265 = 0xFF00;
public static final int BUFFER_TYPE_PICDATA = 0;
public static final int BUFFER_TYPE_SPS = 1;
public static final int BUFFER_TYPE_PPS = 2;
public static final int BUFFER_TYPE_VPS = 3;
public static final int CAPABILITY_DIRECT_SUBMIT = 1;
public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_AVC = 2;
public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_HEVC = 4;
public static final int DR_OK = 0;
public static final int DR_NEED_IDR = -1;
public static final int CONN_STATUS_OKAY = 0;
public static final int CONN_STATUS_POOR = 1;
private static AudioRenderer audioRenderer;
private static VideoDecoderRenderer videoRenderer;
private static NvConnectionListener connectionListener;
static {
System.loadLibrary("moonlight-core");
init();
}
public static int CAPABILITY_SLICES_PER_FRAME(byte slices) {
return slices << 24;
}
public static int bridgeDrSetup(int videoFormat, int width, int height, int redrawRate) {
if (videoRenderer != null) {
return videoRenderer.setup(videoFormat, width, height, redrawRate);
}
else {
return -1;
}
}
public static void bridgeDrStart() {
if (videoRenderer != null) {
videoRenderer.start();
}
}
public static void bridgeDrStop() {
if (videoRenderer != null) {
videoRenderer.stop();
}
}
public static void bridgeDrCleanup() {
if (videoRenderer != null) {
videoRenderer.cleanup();
}
}
public static int bridgeDrSubmitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength,
int decodeUnitType,
int frameNumber, long receiveTimeMs) {
if (videoRenderer != null) {
return videoRenderer.submitDecodeUnit(decodeUnitData, decodeUnitLength,
decodeUnitType, frameNumber, receiveTimeMs);
}
else {
return DR_OK;
}
}
public static int bridgeArInit(int audioConfiguration) {
if (audioRenderer != null) {
return audioRenderer.setup(audioConfiguration);
}
else {
return -1;
}
}
public static void bridgeArStart() {
if (audioRenderer != null) {
audioRenderer.start();
}
}
public static void bridgeArStop() {
if (audioRenderer != null) {
audioRenderer.stop();
}
}
public static void bridgeArCleanup() {
if (audioRenderer != null) {
audioRenderer.cleanup();
}
}
public static void bridgeArPlaySample(short[] pcmData) {
if (audioRenderer != null) {
audioRenderer.playDecodedAudio(pcmData);
}
}
public static void bridgeClStageStarting(int stage) {
if (connectionListener != null) {
connectionListener.stageStarting(getStageName(stage));
}
}
public static void bridgeClStageComplete(int stage) {
if (connectionListener != null) {
connectionListener.stageComplete(getStageName(stage));
}
}
public static void bridgeClStageFailed(int stage, long errorCode) {
if (connectionListener != null) {
connectionListener.stageFailed(getStageName(stage), errorCode);
}
}
public static void bridgeClConnectionStarted() {
if (connectionListener != null) {
connectionListener.connectionStarted();
}
}
public static void bridgeClConnectionTerminated(long errorCode) {
if (connectionListener != null) {
connectionListener.connectionTerminated(errorCode);
}
}
public static void bridgeClRumble(short controllerNumber, short lowFreqMotor, short highFreqMotor) {
if (connectionListener != null) {
connectionListener.rumble(controllerNumber, lowFreqMotor, highFreqMotor);
}
}
public static void bridgeClConnectionStatusUpdate(int connectionStatus) {
if (connectionListener != null) {
connectionListener.connectionStatusUpdate(connectionStatus);
}
}
public static void setupBridge(VideoDecoderRenderer videoRenderer, AudioRenderer audioRenderer, NvConnectionListener connectionListener) {
MoonBridge.videoRenderer = videoRenderer;
MoonBridge.audioRenderer = audioRenderer;
MoonBridge.connectionListener = connectionListener;
}
public static void cleanupBridge() {
MoonBridge.videoRenderer = null;
MoonBridge.audioRenderer = null;
MoonBridge.connectionListener = null;
}
public static native int startConnection(String address, String appVersion, String gfeVersion,
int width, int height, int fps,
int bitrate, int packetSize, int streamingRemotely,
int audioConfiguration, boolean supportsHevc,
boolean enableHdr,
int hevcBitratePercentageMultiplier,
int clientRefreshRateX100,
byte[] riAesKey, byte[] riAesIv,
int videoCapabilities);
public static native void stopConnection();
public static native void interruptConnection();
public static native void sendMouseMove(short deltaX, short deltaY);
public static native void sendMouseButton(byte buttonEvent, byte mouseButton);
public static native void sendMultiControllerInput(short controllerNumber,
short activeGamepadMask, short buttonFlags,
byte leftTrigger, byte rightTrigger,
short leftStickX, short leftStickY,
short rightStickX, short rightStickY);
public static native void sendControllerInput(short buttonFlags,
byte leftTrigger, byte rightTrigger,
short leftStickX, short leftStickY,
short rightStickX, short rightStickY);
public static native void sendKeyboardInput(short keyMap, byte keyDirection, byte modifier);
public static native void sendMouseScroll(byte scrollClicks);
public static native String getStageName(int stage);
public static native String findExternalAddressIP4(String stunHostName, int stunPort);
public static native int getPendingAudioFrames();
public static native int getPendingVideoFrames();
public static native void init();
}

View File

@@ -0,0 +1,65 @@
package com.limelight.nvstream.mdns;
import java.net.Inet6Address;
import java.net.InetAddress;
public class MdnsComputer {
private InetAddress localAddr;
private Inet6Address v6Addr;
private String name;
public MdnsComputer(String name, InetAddress localAddress, Inet6Address v6Addr) {
this.name = name;
this.localAddr = localAddress;
this.v6Addr = v6Addr;
}
public String getName() {
return name;
}
public InetAddress getLocalAddress() {
return localAddr;
}
public Inet6Address getIpv6Address() {
return v6Addr;
}
@Override
public int hashCode() {
return name.hashCode();
}
@Override
public boolean equals(Object o) {
if (o instanceof MdnsComputer) {
MdnsComputer other = (MdnsComputer)o;
if (!other.name.equals(name)) {
return false;
}
if ((other.localAddr != null && localAddr == null) ||
(other.localAddr == null && localAddr != null) ||
(other.localAddr != null && !other.localAddr.equals(localAddr))) {
return false;
}
if ((other.v6Addr != null && v6Addr == null) ||
(other.v6Addr == null && v6Addr != null) ||
(other.v6Addr != null && !other.v6Addr.equals(v6Addr))) {
return false;
}
return true;
}
return false;
}
@Override
public String toString() {
return "["+name+" - "+localAddr+" - "+v6Addr+"]";
}
}

View File

@@ -0,0 +1,408 @@
package com.limelight.nvstream.mdns;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import javax.jmdns.JmmDNS;
import javax.jmdns.NetworkTopologyDiscovery;
import javax.jmdns.ServiceEvent;
import javax.jmdns.ServiceInfo;
import javax.jmdns.ServiceListener;
import javax.jmdns.impl.NetworkTopologyDiscoveryImpl;
import com.limelight.LimeLog;
public class MdnsDiscoveryAgent implements ServiceListener {
public static final String SERVICE_TYPE = "_nvstream._tcp.local.";
private MdnsDiscoveryListener listener;
private Thread discoveryThread;
private HashMap<InetAddress, MdnsComputer> computers = new HashMap<InetAddress, MdnsComputer>();
private HashSet<String> pendingResolution = new HashSet<String>();
// The resolver factory's instance member has a static lifetime which
// means our ref count and listener must be static also.
private static int resolverRefCount = 0;
private static HashSet<ServiceListener> listeners = new HashSet<ServiceListener>();
private static ServiceListener nvstreamListener = new ServiceListener() {
@Override
public void serviceAdded(ServiceEvent event) {
HashSet<ServiceListener> localListeners;
// Copy the listener set into a new set so we can invoke
// the callbacks without holding the listeners monitor the
// whole time.
synchronized (listeners) {
localListeners = new HashSet<ServiceListener>(listeners);
}
for (ServiceListener listener : localListeners) {
listener.serviceAdded(event);
}
}
@Override
public void serviceRemoved(ServiceEvent event) {
HashSet<ServiceListener> localListeners;
// Copy the listener set into a new set so we can invoke
// the callbacks without holding the listeners monitor the
// whole time.
synchronized (listeners) {
localListeners = new HashSet<ServiceListener>(listeners);
}
for (ServiceListener listener : localListeners) {
listener.serviceRemoved(event);
}
}
@Override
public void serviceResolved(ServiceEvent event) {
HashSet<ServiceListener> localListeners;
// Copy the listener set into a new set so we can invoke
// the callbacks without holding the listeners monitor the
// whole time.
synchronized (listeners) {
localListeners = new HashSet<ServiceListener>(listeners);
}
for (ServiceListener listener : localListeners) {
listener.serviceResolved(event);
}
}
};
public static class MyNetworkTopologyDiscovery extends NetworkTopologyDiscoveryImpl {
@Override
public boolean useInetAddress(NetworkInterface networkInterface, InetAddress interfaceAddress) {
// This is an copy of jmDNS's implementation, except we omit the multicast check, since
// it seems at least some devices lie about interfaces not supporting multicast when they really do.
try {
if (!networkInterface.isUp()) {
return false;
}
/*
if (!networkInterface.supportsMulticast()) {
return false;
}
*/
if (networkInterface.isLoopback()) {
return false;
}
return true;
} catch (Exception exception) {
return false;
}
}
};
static {
// Override jmDNS's default topology discovery class with ours
NetworkTopologyDiscovery.Factory.setClassDelegate(new NetworkTopologyDiscovery.Factory.ClassDelegate() {
@Override
public NetworkTopologyDiscovery newNetworkTopologyDiscovery() {
return new MyNetworkTopologyDiscovery();
}
});
}
private static JmmDNS referenceResolver() {
synchronized (MdnsDiscoveryAgent.class) {
JmmDNS instance = JmmDNS.Factory.getInstance();
if (++resolverRefCount == 1) {
// This will cause the listener to be invoked for known hosts immediately.
// JmDNS only supports one listener per service, so we have to do this here
// with a static listener.
instance.addServiceListener(SERVICE_TYPE, nvstreamListener);
}
return instance;
}
}
private static void dereferenceResolver() {
synchronized (MdnsDiscoveryAgent.class) {
if (--resolverRefCount == 0) {
try {
JmmDNS.Factory.close();
} catch (IOException e) {}
}
}
}
public MdnsDiscoveryAgent(MdnsDiscoveryListener listener) {
this.listener = listener;
}
private void handleResolvedServiceInfo(ServiceInfo info) {
synchronized (pendingResolution) {
pendingResolution.remove(info.getName());
}
try {
handleServiceInfo(info);
} catch (UnsupportedEncodingException e) {
// Invalid DNS response
LimeLog.info("mDNS: Invalid response for machine: "+info.getName());
return;
}
}
private Inet6Address getLocalAddress(Inet6Address[] addresses) {
for (Inet6Address addr : addresses) {
if (addr.isLinkLocalAddress() || addr.isSiteLocalAddress()) {
return addr;
}
// fc00::/7 - ULAs
else if ((addr.getAddress()[0] & 0xfe) == 0xfc) {
return addr;
}
}
return null;
}
private Inet6Address getLinkLocalAddress(Inet6Address[] addresses) {
for (Inet6Address addr : addresses) {
if (addr.isLinkLocalAddress()) {
LimeLog.info("Found link-local address: "+addr.getHostAddress());
return addr;
}
}
return null;
}
private Inet6Address getBestIpv6Address(Inet6Address[] addresses) {
// First try to find a link local address, so we can match the interface identifier
// with a global address (this will work for SLAAC but not DHCPv6).
Inet6Address linkLocalAddr = getLinkLocalAddress(addresses);
// We will try once to match a SLAAC interface suffix, then
// pick the first matching address
for (int tries = 0; tries < 2; tries++) {
// We assume the addresses are already sorted in descending order
// of preference from Bonjour.
for (Inet6Address addr : addresses) {
if (addr.isLinkLocalAddress() || addr.isSiteLocalAddress() || addr.isLoopbackAddress()) {
// Link-local, site-local, and loopback aren't global
LimeLog.info("Ignoring non-global address: "+addr.getHostAddress());
continue;
}
byte[] addrBytes = addr.getAddress();
// 2002::/16
if (addrBytes[0] == 0x20 && addrBytes[1] == 0x02) {
// 6to4 has horrible performance
LimeLog.info("Ignoring 6to4 address: "+addr.getHostAddress());
continue;
}
// 2001::/32
else if (addrBytes[0] == 0x20 && addrBytes[1] == 0x01 && addrBytes[2] == 0x00 && addrBytes[3] == 0x00) {
// Teredo also has horrible performance
LimeLog.info("Ignoring Teredo address: "+addr.getHostAddress());
continue;
}
// fc00::/7
else if ((addrBytes[0] & 0xfe) == 0xfc) {
// ULAs aren't global
LimeLog.info("Ignoring ULA: "+addr.getHostAddress());
continue;
}
// Compare the final 64-bit interface identifier and skip the address
// if it doesn't match our link-local address.
if (linkLocalAddr != null && tries == 0) {
boolean matched = true;
for (int i = 8; i < 16; i++) {
if (linkLocalAddr.getAddress()[i] != addr.getAddress()[i]) {
matched = false;
break;
}
}
if (!matched) {
LimeLog.info("Ignoring non-matching global address: "+addr.getHostAddress());
continue;
}
}
return addr;
}
}
return null;
}
private void handleServiceInfo(ServiceInfo info) throws UnsupportedEncodingException {
Inet4Address v4Addrs[] = info.getInet4Addresses();
Inet6Address v6Addrs[] = info.getInet6Addresses();
LimeLog.info("mDNS: "+info.getName()+" has "+v4Addrs.length+" IPv4 addresses");
LimeLog.info("mDNS: "+info.getName()+" has "+v6Addrs.length+" IPv6 addresses");
Inet6Address v6GlobalAddr = getBestIpv6Address(v6Addrs);
// Add a computer object for each IPv4 address reported by the PC
for (Inet4Address v4Addr : v4Addrs) {
synchronized (computers) {
MdnsComputer computer = new MdnsComputer(info.getName(), v4Addr, v6GlobalAddr);
if (computers.put(computer.getLocalAddress(), computer) == null) {
// This was a new entry
listener.notifyComputerAdded(computer);
}
}
}
// If there were no IPv4 addresses, use IPv6 for registration
if (v4Addrs.length == 0) {
Inet6Address v6LocalAddr = getLocalAddress(v6Addrs);
if (v6LocalAddr != null || v6GlobalAddr != null) {
MdnsComputer computer = new MdnsComputer(info.getName(), v6LocalAddr, v6GlobalAddr);
if (computers.put(v6LocalAddr != null ?
computer.getLocalAddress() : computer.getIpv6Address(), computer) == null) {
// This was a new entry
listener.notifyComputerAdded(computer);
}
}
}
}
public void startDiscovery(final int discoveryIntervalMs) {
// Kill any existing discovery before starting a new one
stopDiscovery();
// Add our listener to the set
synchronized (listeners) {
listeners.add(MdnsDiscoveryAgent.this);
}
discoveryThread = new Thread() {
@Override
public void run() {
// This may result in listener callbacks so we must register
// our listener first.
JmmDNS resolver = referenceResolver();
try {
while (!Thread.interrupted()) {
// Start an mDNS request
resolver.requestServiceInfo(SERVICE_TYPE, null, discoveryIntervalMs);
// Run service resolution again for pending machines
ArrayList<String> pendingNames;
synchronized (pendingResolution) {
pendingNames = new ArrayList<String>(pendingResolution);
}
for (String name : pendingNames) {
LimeLog.info("mDNS: Retrying service resolution for machine: "+name);
ServiceInfo[] infos = resolver.getServiceInfos(SERVICE_TYPE, name, 500);
if (infos != null && infos.length != 0) {
LimeLog.info("mDNS: Resolved (retry) with "+infos.length+" service entries");
for (ServiceInfo svcinfo : infos) {
handleResolvedServiceInfo(svcinfo);
}
}
}
// Wait for the next polling interval
try {
Thread.sleep(discoveryIntervalMs);
} catch (InterruptedException e) {
break;
}
}
}
finally {
// Dereference the resolver
dereferenceResolver();
}
}
};
discoveryThread.setName("mDNS Discovery Thread");
discoveryThread.start();
}
public void stopDiscovery() {
// Remove our listener from the set
synchronized (listeners) {
listeners.remove(MdnsDiscoveryAgent.this);
}
// If there's already a running thread, interrupt it
if (discoveryThread != null) {
discoveryThread.interrupt();
discoveryThread = null;
}
}
public List<MdnsComputer> getComputerSet() {
synchronized (computers) {
return new ArrayList<MdnsComputer>(computers.values());
}
}
@Override
public void serviceAdded(ServiceEvent event) {
LimeLog.info("mDNS: Machine appeared: "+event.getInfo().getName());
ServiceInfo info = event.getDNS().getServiceInfo(SERVICE_TYPE, event.getInfo().getName(), 500);
if (info == null) {
// This machine is pending resolution
synchronized (pendingResolution) {
pendingResolution.add(event.getInfo().getName());
}
return;
}
LimeLog.info("mDNS: Resolved (blocking)");
handleResolvedServiceInfo(info);
}
@Override
public void serviceRemoved(ServiceEvent event) {
LimeLog.info("mDNS: Machine disappeared: "+event.getInfo().getName());
Inet4Address v4Addrs[] = event.getInfo().getInet4Addresses();
for (Inet4Address addr : v4Addrs) {
synchronized (computers) {
MdnsComputer computer = computers.remove(addr);
if (computer != null) {
listener.notifyComputerRemoved(computer);
break;
}
}
}
Inet6Address v6Addrs[] = event.getInfo().getInet6Addresses();
for (Inet6Address addr : v6Addrs) {
synchronized (computers) {
MdnsComputer computer = computers.remove(addr);
if (computer != null) {
listener.notifyComputerRemoved(computer);
break;
}
}
}
}
@Override
public void serviceResolved(ServiceEvent event) {
// We handle this synchronously
}
}

View File

@@ -0,0 +1,7 @@
package com.limelight.nvstream.mdns;
public interface MdnsDiscoveryListener {
void notifyComputerAdded(MdnsComputer computer);
void notifyComputerRemoved(MdnsComputer computer);
void notifyDiscoveryFailure(Exception e);
}

View File

@@ -0,0 +1,99 @@
package com.limelight.nvstream.wol;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.util.Scanner;
import com.limelight.LimeLog;
import com.limelight.nvstream.http.ComputerDetails;
public class WakeOnLanSender {
private static final int[] PORTS_TO_TRY = new int[] {
7, 9, // Standard WOL ports
47998, 47999, 48000, 48002, 48010 // Ports opened by GFE
};
public static void sendWolPacket(ComputerDetails computer) throws IOException {
DatagramSocket sock = new DatagramSocket(0);
byte[] payload = createWolPayload(computer);
IOException lastException = null;
boolean sentWolPacket = false;
try {
// Try all resolved remote and local addresses and IPv4 broadcast address.
// The broadcast address is required to avoid stale ARP cache entries
// making the sleeping machine unreachable.
for (String unresolvedAddress : new String[] {
computer.localAddress, computer.remoteAddress, computer.manualAddress, computer.ipv6Address, "255.255.255.255"
}) {
if (unresolvedAddress == null) {
continue;
}
try {
for (InetAddress resolvedAddress : InetAddress.getAllByName(unresolvedAddress)) {
// Try all the ports for each resolved address
for (int port : PORTS_TO_TRY) {
DatagramPacket dp = new DatagramPacket(payload, payload.length);
dp.setAddress(resolvedAddress);
dp.setPort(port);
sock.send(dp);
sentWolPacket = true;
}
}
} catch (IOException e) {
// We may have addresses that don't resolve on this subnet,
// but don't throw and exit the whole function if that happens.
// We'll throw it at the end if we didn't send a single packet.
e.printStackTrace();
lastException = e;
}
}
} finally {
sock.close();
}
// Propagate the DNS resolution exception if we didn't
// manage to get a single packet out to the host.
if (!sentWolPacket && lastException != null) {
throw lastException;
}
}
private static byte[] macStringToBytes(String macAddress) {
byte[] macBytes = new byte[6];
@SuppressWarnings("resource")
Scanner scan = new Scanner(macAddress).useDelimiter(":");
for (int i = 0; i < macBytes.length && scan.hasNext(); i++) {
try {
macBytes[i] = (byte) Integer.parseInt(scan.next(), 16);
} catch (NumberFormatException e) {
LimeLog.warning("Malformed MAC address: "+macAddress+" (index: "+i+")");
break;
}
}
scan.close();
return macBytes;
}
private static byte[] createWolPayload(ComputerDetails computer) {
byte[] payload = new byte[102];
byte[] macAddress = macStringToBytes(computer.macAddress);
int i;
// 6 bytes of FF
for (i = 0; i < 6; i++) {
payload[i] = (byte)0xFF;
}
// 16 repetitions of the MAC address
for (int j = 0; j < 16; j++) {
System.arraycopy(macAddress, 0, payload, i, macAddress.length);
i += macAddress.length;
}
return payload;
}
}