diff --git a/app/build.gradle b/app/build.gradle
index 26d5f84c1e..a67c804797 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -5,53 +5,31 @@ android {
compileSdk 34
- namespace 'com.limelight'
+ namespace 'com.winhanced.wincaster'
defaultConfig {
minSdk 21
targetSdk 34
- versionName "12.1"
- versionCode = 314
+ applicationId "com.winhanced.wincaster"
+
+ versionName "1.0.0"
+ versionCode = 1
// Generate native debug symbols to allow Google Play to symbolicate our native crashes
ndk.debugSymbolLevel = 'FULL'
- }
-
- flavorDimensions.add("root")
-
- buildFeatures {
- buildConfig = true
- }
- productFlavors {
- root {
- // Android O has native mouse capture, so don't show the rooted
- // version to devices running O on the Play Store.
- maxSdk 25
-
- externalNativeBuild {
- ndkBuild {
- arguments "PRODUCT_FLAVOR=root"
- }
+ externalNativeBuild {
+ ndkBuild {
+ arguments "PRODUCT_FLAVOR=nonRoot"
}
-
- applicationId "com.limelight.root"
- dimension "root"
- buildConfigField "boolean", "ROOT_BUILD", "true"
}
- nonRoot {
- externalNativeBuild {
- ndkBuild {
- arguments "PRODUCT_FLAVOR=nonRoot"
- }
- }
+ buildConfigField "boolean", "ROOT_BUILD", "false"
+ }
- applicationId "com.limelight"
- dimension "root"
- buildConfigField "boolean", "ROOT_BUILD", "false"
- }
+ buildFeatures {
+ buildConfig = true
}
compileOptions {
@@ -82,46 +60,13 @@ android {
buildTypes {
debug {
applicationIdSuffix ".debug"
- resValue "string", "app_label", "Moonlight (Debug)"
- resValue "string", "app_label_root", "Moonlight (Root Debug)"
+ resValue "string", "app_label", "WinCaster (Debug)"
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
release {
- // To whomever is releasing/using an APK in release mode with
- // Moonlight's official application ID, please stop. I see every
- // single one of your crashes in my Play Console and it makes
- // Moonlight's reliability look worse and makes it more difficult
- // to distinguish real crashes from your crashy VR app. Seriously,
- // 44 of the *same* native crash in 72 hours and a few each of
- // several other crashes.
- //
- // This is technically not your fault. I would have hoped Google
- // would validate the signature of the APK before attributing
- // the crash to it. I asked their Play Store support about this
- // and they said they don't and don't have plans to, so that sucks.
- //
- // In any case, it's bad form to release an APK using someone
- // else's application ID. There is no legitimate reason, that
- // anyone would need to comment out the following line, except me
- // when I release an official signed Moonlight build. If you feel
- // like doing so would solve something, I can tell you it will not.
- // You can't upgrade an app while retaining data without having the
- // same signature as the official version. Nor can you post it on
- // the Play Store, since that application ID is already taken.
- // Reputable APK hosting websites similarly validate the signature
- // is consistent with the Play Store and won't allow an APK that
- // isn't signed the same as the original.
- //
- // I wish any and all people using Moonlight as the basis of other
- // cool projects the best of luck with their efforts. All I ask
- // is to please change the applicationId before you publish.
- //
- // TL;DR: Leave the following line alone!
- applicationIdSuffix ".unofficial"
- resValue "string", "app_label", "Moonlight"
- resValue "string", "app_label_root", "Moonlight (Root)"
+ resValue "string", "app_label", "WinCaster"
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7be9a20bf1..2131681ef3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,6 +9,11 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= Build.VERSION_CODES.O) {
+ startForegroundService(winCasterServiceIntent);
+ } else {
+ startService(winCasterServiceIntent);
+ }
+
+ // Start WinCaster mDNS advertiser
+ winCasterAdvertiser = new WinCasterAdvertiser(this);
+ winCasterAdvertiser.start();
}
private void startComputerUpdates() {
@@ -297,6 +312,11 @@ public void onDestroy() {
if (managerBinder != null) {
unbindService(serviceConnection);
}
+
+ // Stop WinCaster mDNS advertiser
+ if (winCasterAdvertiser != null) {
+ winCasterAdvertiser.stop();
+ }
}
@Override
diff --git a/app/src/main/java/com/limelight/computers/ComputerManagerService.java b/app/src/main/java/com/limelight/computers/ComputerManagerService.java
index 990a8953f2..93056f1b4c 100644
--- a/app/src/main/java/com/limelight/computers/ComputerManagerService.java
+++ b/app/src/main/java/com/limelight/computers/ComputerManagerService.java
@@ -290,6 +290,16 @@ public ComputerDetails getComputer(String uuid) {
return null;
}
+ public java.util.List getComputers() {
+ java.util.List computers = new java.util.ArrayList<>();
+ synchronized (pollingTuples) {
+ for (PollingTuple tuple : pollingTuples) {
+ computers.add(tuple.computer);
+ }
+ }
+ return computers;
+ }
+
public void invalidateStateForComputer(String uuid) {
synchronized (pollingTuples) {
for (PollingTuple tuple : pollingTuples) {
diff --git a/app/src/main/java/com/limelight/wincaster/WinCasterAdvertiser.java b/app/src/main/java/com/limelight/wincaster/WinCasterAdvertiser.java
new file mode 100644
index 0000000000..c07b49f957
--- /dev/null
+++ b/app/src/main/java/com/limelight/wincaster/WinCasterAdvertiser.java
@@ -0,0 +1,245 @@
+package com.limelight.wincaster;
+
+import android.content.Context;
+import android.net.nsd.NsdManager;
+import android.net.nsd.NsdServiceInfo;
+import android.net.wifi.WifiManager;
+import android.os.Build;
+
+import com.limelight.LimeLog;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import javax.jmdns.JmDNS;
+import javax.jmdns.ServiceInfo;
+
+public class WinCasterAdvertiser {
+ private static final String TAG = "WinCasterAdvertiser";
+ private static final String SERVICE_TYPE = "_wincaster._tcp.";
+ private static final String SERVICE_NAME = "WinCaster";
+ private static final int SERVICE_PORT = 47990;
+ private static final String VERSION = "1.0.0";
+
+ private final Context context;
+ private final AtomicBoolean isAdvertising = new AtomicBoolean(false);
+
+ // NsdManager implementation (Android 14+)
+ private NsdManager nsdManager;
+ private NsdManager.RegistrationListener registrationListener;
+
+ // JmDNS implementation (older Android versions)
+ private JmDNS jmdns;
+ private WifiManager.MulticastLock multicastLock;
+ private Thread jmdnsThread;
+
+ public WinCasterAdvertiser(Context context) {
+ this.context = context.getApplicationContext();
+ }
+
+ public void start() {
+ if (isAdvertising.getAndSet(true)) {
+ LimeLog.info(TAG + ": Already advertising");
+ return;
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ // Use NsdManager on Android 14+ (API 34)
+ startNsdAdvertising();
+ } else {
+ // Use JmDNS on older versions
+ startJmdnsAdvertising();
+ }
+ }
+
+ public void stop() {
+ if (!isAdvertising.getAndSet(false)) {
+ return;
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ stopNsdAdvertising();
+ } else {
+ stopJmdnsAdvertising();
+ }
+ }
+
+ private void startNsdAdvertising() {
+ LimeLog.info(TAG + ": Starting NsdManager advertising");
+
+ nsdManager = (NsdManager) context.getSystemService(Context.NSD_SERVICE);
+ if (nsdManager == null) {
+ LimeLog.warning(TAG + ": NsdManager not available");
+ isAdvertising.set(false);
+ return;
+ }
+
+ NsdServiceInfo serviceInfo = new NsdServiceInfo();
+ serviceInfo.setServiceName(getServiceName());
+ serviceInfo.setServiceType(SERVICE_TYPE);
+ serviceInfo.setPort(SERVICE_PORT);
+
+ // Add TXT records
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ serviceInfo.setAttribute("name", Build.MODEL);
+ serviceInfo.setAttribute("version", VERSION);
+ serviceInfo.setAttribute("platform", "android");
+ }
+
+ registrationListener = new NsdManager.RegistrationListener() {
+ @Override
+ public void onServiceRegistered(NsdServiceInfo serviceInfo) {
+ LimeLog.info(TAG + ": Service registered: " + serviceInfo.getServiceName());
+ }
+
+ @Override
+ public void onRegistrationFailed(NsdServiceInfo serviceInfo, int errorCode) {
+ LimeLog.warning(TAG + ": Registration failed: " + errorCode);
+ isAdvertising.set(false);
+ }
+
+ @Override
+ public void onServiceUnregistered(NsdServiceInfo serviceInfo) {
+ LimeLog.info(TAG + ": Service unregistered");
+ }
+
+ @Override
+ public void onUnregistrationFailed(NsdServiceInfo serviceInfo, int errorCode) {
+ LimeLog.warning(TAG + ": Unregistration failed: " + errorCode);
+ }
+ };
+
+ try {
+ nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener);
+ } catch (Exception e) {
+ LimeLog.warning(TAG + ": Failed to register service: " + e.getMessage());
+ isAdvertising.set(false);
+ }
+ }
+
+ private void stopNsdAdvertising() {
+ LimeLog.info(TAG + ": Stopping NsdManager advertising");
+
+ if (nsdManager != null && registrationListener != null) {
+ try {
+ nsdManager.unregisterService(registrationListener);
+ } catch (Exception e) {
+ LimeLog.warning(TAG + ": Failed to unregister service: " + e.getMessage());
+ }
+ }
+ nsdManager = null;
+ registrationListener = null;
+ }
+
+ private void startJmdnsAdvertising() {
+ LimeLog.info(TAG + ": Starting JmDNS advertising");
+
+ jmdnsThread = new Thread(() -> {
+ try {
+ // Acquire multicast lock
+ WifiManager wifi = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
+ if (wifi != null) {
+ multicastLock = wifi.createMulticastLock("WinCasterAdvertiser");
+ multicastLock.setReferenceCounted(true);
+ multicastLock.acquire();
+ }
+
+ // Get local IP address
+ InetAddress localAddress = getLocalIpAddress();
+ if (localAddress == null) {
+ LimeLog.warning(TAG + ": Could not determine local IP address");
+ isAdvertising.set(false);
+ return;
+ }
+
+ // Create JmDNS instance
+ jmdns = JmDNS.create(localAddress, getServiceName());
+
+ // Create TXT record map
+ Map txtRecords = new HashMap<>();
+ txtRecords.put("name", Build.MODEL);
+ txtRecords.put("version", VERSION);
+ txtRecords.put("platform", "android");
+
+ // Register service
+ ServiceInfo serviceInfo = ServiceInfo.create(
+ SERVICE_TYPE + "local.",
+ getServiceName(),
+ SERVICE_PORT,
+ 0, // weight
+ 0, // priority
+ txtRecords
+ );
+
+ jmdns.registerService(serviceInfo);
+ LimeLog.info(TAG + ": JmDNS service registered");
+
+ } catch (IOException e) {
+ LimeLog.warning(TAG + ": JmDNS error: " + e.getMessage());
+ isAdvertising.set(false);
+ }
+ });
+ jmdnsThread.start();
+ }
+
+ private void stopJmdnsAdvertising() {
+ LimeLog.info(TAG + ": Stopping JmDNS advertising");
+
+ if (jmdns != null) {
+ try {
+ jmdns.unregisterAllServices();
+ jmdns.close();
+ } catch (IOException e) {
+ LimeLog.warning(TAG + ": Error closing JmDNS: " + e.getMessage());
+ }
+ jmdns = null;
+ }
+
+ if (multicastLock != null && multicastLock.isHeld()) {
+ multicastLock.release();
+ multicastLock = null;
+ }
+
+ if (jmdnsThread != null) {
+ jmdnsThread.interrupt();
+ jmdnsThread = null;
+ }
+ }
+
+ private String getServiceName() {
+ // Use device model as service name, sanitized for DNS
+ String name = Build.MODEL.replaceAll("[^a-zA-Z0-9\\-]", "-");
+ if (name.length() > 63) {
+ name = name.substring(0, 63);
+ }
+ return SERVICE_NAME + "-" + name;
+ }
+
+ private InetAddress getLocalIpAddress() {
+ try {
+ WifiManager wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
+ if (wifiManager != null) {
+ int ipAddress = wifiManager.getConnectionInfo().getIpAddress();
+ if (ipAddress != 0) {
+ byte[] bytes = new byte[]{
+ (byte) (ipAddress & 0xff),
+ (byte) (ipAddress >> 8 & 0xff),
+ (byte) (ipAddress >> 16 & 0xff),
+ (byte) (ipAddress >> 24 & 0xff)
+ };
+ return InetAddress.getByAddress(bytes);
+ }
+ }
+ } catch (Exception e) {
+ LimeLog.warning(TAG + ": Error getting local IP: " + e.getMessage());
+ }
+ return null;
+ }
+
+ public boolean isAdvertising() {
+ return isAdvertising.get();
+ }
+}
diff --git a/app/src/main/java/com/limelight/wincaster/WinCasterBootReceiver.java b/app/src/main/java/com/limelight/wincaster/WinCasterBootReceiver.java
new file mode 100644
index 0000000000..3502968860
--- /dev/null
+++ b/app/src/main/java/com/limelight/wincaster/WinCasterBootReceiver.java
@@ -0,0 +1,51 @@
+package com.limelight.wincaster;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+
+import com.limelight.LimeLog;
+
+public class WinCasterBootReceiver extends BroadcastReceiver {
+ private static final String TAG = "WinCasterBootReceiver";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent == null || intent.getAction() == null) {
+ return;
+ }
+
+ String action = intent.getAction();
+ LimeLog.info(TAG + ": Received action: " + action);
+
+ switch (action) {
+ case Intent.ACTION_BOOT_COMPLETED:
+ case Intent.ACTION_LOCKED_BOOT_COMPLETED:
+ case "android.intent.action.QUICKBOOT_POWERON":
+ case "com.htc.intent.action.QUICKBOOT_POWERON":
+ startWinCasterService(context);
+ break;
+ default:
+ LimeLog.info(TAG + ": Ignoring unknown action: " + action);
+ break;
+ }
+ }
+
+ private void startWinCasterService(Context context) {
+ LimeLog.info(TAG + ": Starting WinCasterCommandService on boot");
+
+ Intent serviceIntent = new Intent(context, WinCasterCommandService.class);
+
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(serviceIntent);
+ } else {
+ context.startService(serviceIntent);
+ }
+ LimeLog.info(TAG + ": WinCasterCommandService started successfully");
+ } catch (Exception e) {
+ LimeLog.warning(TAG + ": Failed to start WinCasterCommandService: " + e.getMessage());
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/wincaster/WinCasterCommandService.java b/app/src/main/java/com/limelight/wincaster/WinCasterCommandService.java
new file mode 100644
index 0000000000..ebe676a046
--- /dev/null
+++ b/app/src/main/java/com/limelight/wincaster/WinCasterCommandService.java
@@ -0,0 +1,374 @@
+package com.limelight.wincaster;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.os.Binder;
+import android.os.Build;
+import android.os.IBinder;
+
+import com.limelight.Game;
+import com.limelight.LimeLog;
+import com.limelight.PcView;
+import com.limelight.R;
+import com.limelight.computers.ComputerManagerService;
+import com.limelight.nvstream.http.ComputerDetails;
+import com.limelight.nvstream.http.NvApp;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.SocketException;
+import java.security.cert.CertificateEncodingException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public class WinCasterCommandService extends Service {
+ private static final String TAG = "WinCasterCommandService";
+ private static final int TCP_PORT = 47990;
+ private static final String NOTIFICATION_CHANNEL_ID = "wincaster_service";
+ private static final int NOTIFICATION_ID = 1;
+
+ private ServerSocket serverSocket;
+ private ExecutorService executorService;
+ private volatile boolean isRunning = false;
+ private ComputerManagerService.ComputerManagerBinder managerBinder;
+ private boolean isBound = false;
+
+ private final IBinder binder = new LocalBinder();
+
+ public class LocalBinder extends Binder {
+ public WinCasterCommandService getService() {
+ return WinCasterCommandService.this;
+ }
+ }
+
+ private final ServiceConnection serviceConnection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder service) {
+ managerBinder = (ComputerManagerService.ComputerManagerBinder) service;
+ isBound = true;
+ LimeLog.info(TAG + ": Connected to ComputerManagerService");
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ managerBinder = null;
+ isBound = false;
+ LimeLog.info(TAG + ": Disconnected from ComputerManagerService");
+ }
+ };
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ LimeLog.info(TAG + ": Service created");
+
+ createNotificationChannel();
+ startForeground(NOTIFICATION_ID, createNotification());
+
+ // Bind to ComputerManagerService
+ Intent intent = new Intent(this, ComputerManagerService.class);
+ bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
+
+ executorService = Executors.newCachedThreadPool();
+ startServer();
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ LimeLog.info(TAG + ": Service started");
+ return START_STICKY;
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return binder;
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ LimeLog.info(TAG + ": Service destroyed");
+
+ stopServer();
+
+ if (isBound) {
+ unbindService(serviceConnection);
+ isBound = false;
+ }
+
+ if (executorService != null) {
+ executorService.shutdownNow();
+ }
+ }
+
+ private void createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ NotificationChannel channel = new NotificationChannel(
+ NOTIFICATION_CHANNEL_ID,
+ "WinCaster Remote Control",
+ NotificationManager.IMPORTANCE_LOW
+ );
+ channel.setDescription("WinCaster remote control service");
+ NotificationManager manager = getSystemService(NotificationManager.class);
+ if (manager != null) {
+ manager.createNotificationChannel(channel);
+ }
+ }
+ }
+
+ private Notification createNotification() {
+ Intent notificationIntent = new Intent(this, PcView.class);
+ PendingIntent pendingIntent = PendingIntent.getActivity(
+ this, 0, notificationIntent,
+ PendingIntent.FLAG_IMMUTABLE
+ );
+
+ Notification.Builder builder;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ builder = new Notification.Builder(this, NOTIFICATION_CHANNEL_ID);
+ } else {
+ builder = new Notification.Builder(this);
+ }
+
+ return builder
+ .setContentTitle("WinCaster")
+ .setContentText("Remote control service running")
+ .setSmallIcon(R.drawable.ic_pc)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .build();
+ }
+
+ private void startServer() {
+ isRunning = true;
+ executorService.submit(() -> {
+ try {
+ serverSocket = new ServerSocket(TCP_PORT);
+ LimeLog.info(TAG + ": TCP server listening on port " + TCP_PORT);
+
+ while (isRunning) {
+ try {
+ Socket clientSocket = serverSocket.accept();
+ LimeLog.info(TAG + ": Client connected from " + clientSocket.getInetAddress());
+ executorService.submit(() -> handleClient(clientSocket));
+ } catch (SocketException e) {
+ if (isRunning) {
+ LimeLog.warning(TAG + ": Socket exception: " + e.getMessage());
+ }
+ }
+ }
+ } catch (IOException e) {
+ LimeLog.warning(TAG + ": Failed to start server: " + e.getMessage());
+ }
+ });
+ }
+
+ private void stopServer() {
+ isRunning = false;
+ if (serverSocket != null && !serverSocket.isClosed()) {
+ try {
+ serverSocket.close();
+ } catch (IOException e) {
+ LimeLog.warning(TAG + ": Error closing server socket: " + e.getMessage());
+ }
+ }
+ }
+
+ private void handleClient(Socket clientSocket) {
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
+ PrintWriter writer = new PrintWriter(new OutputStreamWriter(clientSocket.getOutputStream()), true)) {
+
+ clientSocket.setSoTimeout(30000); // 30 second timeout
+
+ String line;
+ while ((line = reader.readLine()) != null) {
+ LimeLog.info(TAG + ": Received: " + line);
+ String response = processCommand(line);
+ writer.println(response);
+ LimeLog.info(TAG + ": Sent: " + response);
+ }
+ } catch (IOException e) {
+ LimeLog.warning(TAG + ": Client handling error: " + e.getMessage());
+ } finally {
+ try {
+ clientSocket.close();
+ } catch (IOException e) {
+ LimeLog.warning(TAG + ": Error closing client socket: " + e.getMessage());
+ }
+ }
+ }
+
+ private String processCommand(String jsonCommand) {
+ try {
+ JSONObject request = new JSONObject(jsonCommand);
+ String command = request.optString("command", "");
+
+ switch (command) {
+ case "ping":
+ return createResponse("pong", null);
+
+ case "status":
+ return getStatus();
+
+ case "stream":
+ return startStream(request);
+
+ case "stop":
+ return stopStream();
+
+ default:
+ return createErrorResponse("Unknown command: " + command);
+ }
+ } catch (JSONException e) {
+ return createErrorResponse("Invalid JSON: " + e.getMessage());
+ }
+ }
+
+ private String getStatus() {
+ try {
+ JSONObject response = new JSONObject();
+ response.put("status", "ready");
+ response.put("clientName", Build.MODEL);
+ response.put("version", "1.0.0");
+ response.put("platform", "android");
+ return response.toString();
+ } catch (JSONException e) {
+ return createErrorResponse("Failed to create status response");
+ }
+ }
+
+ private String startStream(JSONObject request) {
+ if (!isBound || managerBinder == null) {
+ return createErrorResponse("ComputerManagerService not available");
+ }
+
+ String host = request.optString("host", "");
+ String hostUUID = request.optString("hostUUID", "");
+ String hostName = request.optString("hostName", "");
+ String appId = request.optString("appId", "");
+
+ if (host.isEmpty()) {
+ return createErrorResponse("Missing 'host' parameter");
+ }
+
+ // Wait for the manager to be ready
+ managerBinder.waitForReady();
+
+ // Find the computer by UUID or add a new one
+ ComputerDetails computer = null;
+
+ if (!hostUUID.isEmpty()) {
+ computer = managerBinder.getComputer(hostUUID);
+ }
+
+ if (computer == null) {
+ // Computer not found, we need to add it
+ return createErrorResponse("Host not found. Please pair the host first using the app.");
+ }
+
+ if (computer.pairState != com.limelight.nvstream.http.PairingManager.PairState.PAIRED) {
+ return createErrorResponse("Host is not paired. Please pair the host first using the app.");
+ }
+
+ if (computer.state == ComputerDetails.State.OFFLINE || computer.activeAddress == null) {
+ return createErrorResponse("Host is offline");
+ }
+
+ // Create an NvApp for the stream
+ int appIdInt = 0;
+ if (!appId.isEmpty()) {
+ try {
+ appIdInt = Integer.parseInt(appId);
+ } catch (NumberFormatException e) {
+ // If appId is "Desktop" or non-numeric, use the running game or default to Desktop
+ if (computer.runningGameId != 0) {
+ appIdInt = computer.runningGameId;
+ }
+ }
+ } else if (computer.runningGameId != 0) {
+ appIdInt = computer.runningGameId;
+ }
+
+ NvApp app = new NvApp(appId.isEmpty() ? "Desktop" : appId, appIdInt, false);
+
+ // Create intent to start streaming
+ Intent intent = new Intent(this, Game.class);
+ intent.putExtra(Game.EXTRA_HOST, computer.activeAddress.address);
+ intent.putExtra(Game.EXTRA_PORT, computer.activeAddress.port);
+ intent.putExtra(Game.EXTRA_HTTPS_PORT, computer.httpsPort);
+ intent.putExtra(Game.EXTRA_APP_NAME, app.getAppName());
+ intent.putExtra(Game.EXTRA_APP_ID, app.getAppId());
+ intent.putExtra(Game.EXTRA_APP_HDR, app.isHdrSupported());
+ intent.putExtra(Game.EXTRA_UNIQUEID, managerBinder.getUniqueId());
+ intent.putExtra(Game.EXTRA_PC_UUID, computer.uuid);
+ intent.putExtra(Game.EXTRA_PC_NAME, computer.name);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+
+ try {
+ if (computer.serverCert != null) {
+ intent.putExtra(Game.EXTRA_SERVER_CERT, computer.serverCert.getEncoded());
+ }
+ } catch (CertificateEncodingException e) {
+ LimeLog.warning(TAG + ": Failed to encode server certificate: " + e.getMessage());
+ }
+
+ startActivity(intent);
+
+ try {
+ JSONObject response = new JSONObject();
+ response.put("status", "streaming");
+ response.put("clientName", Build.MODEL);
+ response.put("host", computer.name);
+ return response.toString();
+ } catch (JSONException e) {
+ return createErrorResponse("Stream started but failed to create response");
+ }
+ }
+
+ private String stopStream() {
+ // Send broadcast to stop the stream
+ Intent intent = new Intent("com.limelight.STOP_STREAM");
+ sendBroadcast(intent);
+
+ return createResponse("stopped", null);
+ }
+
+ private String createResponse(String status, String message) {
+ try {
+ JSONObject response = new JSONObject();
+ response.put("status", status);
+ if (message != null) {
+ response.put("message", message);
+ }
+ return response.toString();
+ } catch (JSONException e) {
+ return "{\"status\":\"error\",\"message\":\"Failed to create response\"}";
+ }
+ }
+
+ private String createErrorResponse(String message) {
+ try {
+ JSONObject response = new JSONObject();
+ response.put("status", "error");
+ response.put("message", message);
+ return response.toString();
+ } catch (JSONException e) {
+ return "{\"status\":\"error\",\"message\":\"" + message + "\"}";
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/wincaster/WinCasterDeepLinkActivity.java b/app/src/main/java/com/limelight/wincaster/WinCasterDeepLinkActivity.java
new file mode 100644
index 0000000000..a8d839b4bd
--- /dev/null
+++ b/app/src/main/java/com/limelight/wincaster/WinCasterDeepLinkActivity.java
@@ -0,0 +1,188 @@
+package com.limelight.wincaster;
+
+import android.app.Activity;
+import android.app.Service;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.widget.Toast;
+
+import com.limelight.Game;
+import com.limelight.LimeLog;
+import com.limelight.R;
+import com.limelight.computers.ComputerManagerService;
+import com.limelight.nvstream.http.ComputerDetails;
+import com.limelight.nvstream.http.NvApp;
+import com.limelight.nvstream.http.PairingManager;
+
+import java.security.cert.CertificateEncodingException;
+
+public class WinCasterDeepLinkActivity extends Activity {
+ private static final String TAG = "WinCasterDeepLink";
+
+ private ComputerManagerService.ComputerManagerBinder managerBinder;
+ private String host;
+ private String uuid;
+ private String name;
+ private String app;
+
+ private final ServiceConnection serviceConnection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName className, IBinder binder) {
+ final ComputerManagerService.ComputerManagerBinder localBinder =
+ (ComputerManagerService.ComputerManagerBinder) binder;
+
+ // Wait in a separate thread to avoid stalling the UI
+ new Thread(() -> {
+ localBinder.waitForReady();
+ managerBinder = localBinder;
+ runOnUiThread(() -> processDeepLink());
+ }).start();
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName className) {
+ managerBinder = null;
+ }
+ };
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ Intent intent = getIntent();
+ Uri data = intent.getData();
+
+ if (data == null) {
+ LimeLog.warning(TAG + ": No URI data provided");
+ Toast.makeText(this, "Invalid deep link", Toast.LENGTH_SHORT).show();
+ finish();
+ return;
+ }
+
+ LimeLog.info(TAG + ": Processing deep link: " + data.toString());
+
+ // Parse query parameters
+ host = data.getQueryParameter("host");
+ uuid = data.getQueryParameter("uuid");
+ name = data.getQueryParameter("name");
+ app = data.getQueryParameter("app");
+
+ if (host == null && uuid == null) {
+ LimeLog.warning(TAG + ": Missing host or uuid parameter");
+ Toast.makeText(this, "Missing host or uuid parameter", Toast.LENGTH_SHORT).show();
+ finish();
+ return;
+ }
+
+ // Bind to ComputerManagerService
+ bindService(new Intent(this, ComputerManagerService.class),
+ serviceConnection, Service.BIND_AUTO_CREATE);
+ }
+
+ private void processDeepLink() {
+ if (managerBinder == null) {
+ Toast.makeText(this, getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
+ finish();
+ return;
+ }
+
+ // Find the computer
+ ComputerDetails computer = null;
+
+ if (uuid != null && !uuid.isEmpty()) {
+ computer = managerBinder.getComputer(uuid);
+ }
+
+ if (computer == null && host != null && !host.isEmpty()) {
+ // Try to find by host address
+ for (ComputerDetails details : managerBinder.getComputers()) {
+ if (details.activeAddress != null && host.equals(details.activeAddress.address)) {
+ computer = details;
+ break;
+ }
+ if (details.localAddress != null && host.equals(details.localAddress.address)) {
+ computer = details;
+ break;
+ }
+ if (details.remoteAddress != null && host.equals(details.remoteAddress.address)) {
+ computer = details;
+ break;
+ }
+ }
+ }
+
+ if (computer == null) {
+ LimeLog.warning(TAG + ": Host not found in paired computers");
+ Toast.makeText(this, getString(R.string.scut_pc_not_found), Toast.LENGTH_LONG).show();
+ finish();
+ return;
+ }
+
+ if (computer.pairState != PairingManager.PairState.PAIRED) {
+ LimeLog.warning(TAG + ": Host is not paired");
+ Toast.makeText(this, getString(R.string.scut_not_paired), Toast.LENGTH_LONG).show();
+ finish();
+ return;
+ }
+
+ if (computer.state == ComputerDetails.State.OFFLINE || computer.activeAddress == null) {
+ LimeLog.warning(TAG + ": Host is offline");
+ Toast.makeText(this, getString(R.string.error_pc_offline), Toast.LENGTH_SHORT).show();
+ finish();
+ return;
+ }
+
+ // Determine app ID
+ int appId = 0;
+ if (app != null && !app.isEmpty()) {
+ try {
+ appId = Integer.parseInt(app);
+ } catch (NumberFormatException e) {
+ // Non-numeric app ID, use running game or 0
+ if (computer.runningGameId != 0) {
+ appId = computer.runningGameId;
+ }
+ }
+ } else if (computer.runningGameId != 0) {
+ appId = computer.runningGameId;
+ }
+
+ NvApp nvApp = new NvApp(app != null ? app : "Desktop", appId, false);
+
+ // Start streaming
+ Intent streamIntent = new Intent(this, Game.class);
+ streamIntent.putExtra(Game.EXTRA_HOST, computer.activeAddress.address);
+ streamIntent.putExtra(Game.EXTRA_PORT, computer.activeAddress.port);
+ streamIntent.putExtra(Game.EXTRA_HTTPS_PORT, computer.httpsPort);
+ streamIntent.putExtra(Game.EXTRA_APP_NAME, nvApp.getAppName());
+ streamIntent.putExtra(Game.EXTRA_APP_ID, nvApp.getAppId());
+ streamIntent.putExtra(Game.EXTRA_APP_HDR, nvApp.isHdrSupported());
+ streamIntent.putExtra(Game.EXTRA_UNIQUEID, managerBinder.getUniqueId());
+ streamIntent.putExtra(Game.EXTRA_PC_UUID, computer.uuid);
+ streamIntent.putExtra(Game.EXTRA_PC_NAME, computer.name);
+
+ try {
+ if (computer.serverCert != null) {
+ streamIntent.putExtra(Game.EXTRA_SERVER_CERT, computer.serverCert.getEncoded());
+ }
+ } catch (CertificateEncodingException e) {
+ LimeLog.warning(TAG + ": Failed to encode server certificate: " + e.getMessage());
+ }
+
+ LimeLog.info(TAG + ": Starting stream to " + computer.name);
+ startActivity(streamIntent);
+ finish();
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ if (managerBinder != null) {
+ unbindService(serviceConnection);
+ }
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9cc2215aaa..bb5ba92404 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,5 +1,8 @@
+
+ WinCaster
+
PC deleted
PC not paired