diff --git a/.gitignore b/.gitignore index a379cf1db0..f47b020013 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ app/core/src/main/resources/static/index.html # Prerendered per-route SPA pages (OG/social-preview), e.g. compress.html. api-landing.html is source. app/core/src/main/resources/static/*.html !app/core/src/main/resources/static/api-landing.html +!app/core/src/main/resources/static/mobile-upload.html # Prerendered nested-route pages (e.g. settings/people.html) app/core/src/main/resources/static/settings/ app/core/src/main/resources/static/locales/ diff --git a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java index fbcf30fdff..4a9ef0834b 100644 --- a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java @@ -1185,23 +1185,181 @@ public class GeneralUtils { } public String getLocalNetworkIp() { + String routed = detectLocalIpViaDefaultRoute(); + if (routed != null) { + return routed; + } try { - Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); - if (interfaces == null) return null; - while (interfaces.hasMoreElements()) { - NetworkInterface iface = interfaces.nextElement(); - if (!iface.isUp() || iface.isLoopback() || iface.isVirtual()) continue; - Enumeration addresses = iface.getInetAddresses(); - while (addresses.hasMoreElements()) { - InetAddress addr = addresses.nextElement(); - if (addr instanceof Inet4Address && addr.isSiteLocalAddress()) { - return addr.getHostAddress(); - } - } - } + return selectBestSiteLocalIp(collectInterfaceInfo()); } catch (Exception e) { log.warn("Failed to detect local network IP", e); + return null; + } + } + + private String detectLocalIpViaDefaultRoute() { + try (DatagramSocket socket = new DatagramSocket()) { + socket.connect(InetAddress.getByName("8.8.8.8"), 53); + InetAddress local = socket.getLocalAddress(); + if (local instanceof Inet4Address + && !local.isAnyLocalAddress() + && !local.isLoopbackAddress() + && !local.isLinkLocalAddress()) { + return local.getHostAddress(); + } + } catch (Exception e) { + log.debug("Default-route IP detection failed; will scan interfaces", e); } return null; } + + private List collectInterfaceInfo() throws SocketException { + List infos = new ArrayList<>(); + Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); + if (interfaces == null) { + return infos; + } + while (interfaces.hasMoreElements()) { + NetworkInterface iface = interfaces.nextElement(); + + List siteLocalIpv4s = new ArrayList<>(); + Enumeration addresses = iface.getInetAddresses(); + while (addresses.hasMoreElements()) { + InetAddress addr = addresses.nextElement(); + if (addr instanceof Inet4Address && addr.isSiteLocalAddress()) { + siteLocalIpv4s.add(addr.getHostAddress()); + } + } + if (siteLocalIpv4s.isEmpty()) { + continue; + } + + try { + byte[] mac = iface.getHardwareAddress(); + infos.add( + new NetworkInterfaceInfo( + iface.getName(), + iface.getDisplayName(), + iface.getIndex(), + iface.isUp(), + iface.isLoopback(), + iface.isPointToPoint(), + iface.isVirtual(), + mac != null && mac.length > 0, + siteLocalIpv4s)); + } catch (SocketException e) { + log.debug("Skipping interface {} while scanning for local IP", iface.getName(), e); + } + } + return infos; + } + + static String selectBestSiteLocalIp(List interfaces) { + return interfaces.stream() + .filter(i -> i.up() && !i.loopback() && !i.pointToPoint() && !i.virtual()) + .filter(i -> !isLikelyVirtualInterface(i.name(), i.displayName())) + .flatMap( + i -> + i.siteLocalIpv4s().stream() + .map( + ip -> + new ScoredAddress( + ip, + scoreInterface(i, ip), + i.index()))) + .max( + Comparator.comparingInt(ScoredAddress::score) + .thenComparing( + Comparator.comparingInt(ScoredAddress::interfaceIndex) + .reversed())) + .map(ScoredAddress::ip) + .orElse(null); + } + + private static int scoreInterface(NetworkInterfaceInfo iface, String ip) { + int score = 0; + if (isLikelyPhysicalInterface(iface.name(), iface.displayName())) { + score += 100; + } + if (iface.hasHardwareAddress()) { + score += 20; + } + if (ip.startsWith("192.168.")) { + score += 30; + } else if (ip.startsWith("10.")) { + score += 20; + } else { + score += 5; + } + return score; + } + + static boolean isLikelyVirtualInterface(String name, String displayName) { + String n = name == null ? "" : name.toLowerCase(Locale.ROOT); + String d = displayName == null ? "" : displayName.toLowerCase(Locale.ROOT); + String[] namePrefixes = { + "tun", "tap", "utun", "veth", "virbr", "vmnet", "docker", "br-", "wg", "ppp", "awdl", + "llw" + }; + for (String prefix : namePrefixes) { + if (n.startsWith(prefix)) { + return true; + } + } + String[] displayMarkers = { + "vmware", + "virtualbox", + "virtual box", + "vbox", + "hyper-v", + "hyperv", + "vethernet", + "windows subsystem for linux", + "wsl", + "docker", + "tap-windows", + "tunnel", + "vpn", + "zerotier", + "tailscale", + "bluetooth", + "teredo", + "isatap", + "loopback", + "pseudo", + "virtual" + }; + for (String marker : displayMarkers) { + if (d.contains(marker)) { + return true; + } + } + return false; + } + + private static boolean isLikelyPhysicalInterface(String name, String displayName) { + String n = name == null ? "" : name.toLowerCase(Locale.ROOT); + String d = displayName == null ? "" : displayName.toLowerCase(Locale.ROOT); + return n.startsWith("eth") + || n.startsWith("en") + || n.startsWith("wl") + || n.startsWith("em") + || d.contains("ethernet") + || d.contains("wi-fi") + || d.contains("wifi") + || d.contains("wireless"); + } + + record NetworkInterfaceInfo( + String name, + String displayName, + int index, + boolean up, + boolean loopback, + boolean pointToPoint, + boolean virtual, + boolean hasHardwareAddress, + List siteLocalIpv4s) {} + + private record ScoredAddress(String ip, int score, int interfaceIndex) {} } diff --git a/app/common/src/test/java/stirling/software/common/util/GeneralUtilsLocalIpTest.java b/app/common/src/test/java/stirling/software/common/util/GeneralUtilsLocalIpTest.java new file mode 100644 index 0000000000..5e106f096c --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/GeneralUtilsLocalIpTest.java @@ -0,0 +1,114 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.util.GeneralUtils.NetworkInterfaceInfo; + +class GeneralUtilsLocalIpTest { + + private static NetworkInterfaceInfo iface( + String name, String displayName, int index, boolean virtual, String... ips) { + return new NetworkInterfaceInfo( + name, displayName, index, true, false, false, virtual, true, List.of(ips)); + } + + @Test + void prefersPhysicalWifiOverVmwareNatAdapter() { + NetworkInterfaceInfo vmware = + iface("eth5", "VMware Virtual Ethernet Adapter for VMnet8", 5, false, "172.16.1.1"); + NetworkInterfaceInfo wifi = + iface("wlan0", "Intel(R) Wi-Fi 6 AX201", 12, false, "192.168.1.50"); + + assertEquals("192.168.1.50", GeneralUtils.selectBestSiteLocalIp(List.of(vmware, wifi))); + } + + @Test + void excludesHyperVVethernetAdapter() { + NetworkInterfaceInfo hyperv = + iface("ethernet_32770", "Hyper-V Virtual Ethernet Adapter", 3, false, "172.28.0.1"); + NetworkInterfaceInfo ethernet = + iface("eth0", "Realtek PCIe GbE Family Controller", 8, false, "192.168.0.20"); + + assertEquals("192.168.0.20", GeneralUtils.selectBestSiteLocalIp(List.of(hyperv, ethernet))); + } + + @Test + void excludesWslAndDockerBridges() { + NetworkInterfaceInfo wsl = + iface("eth1", "Hyper-V Virtual Ethernet Adapter (WSL)", 70, false, "172.20.0.1"); + NetworkInterfaceInfo docker = iface("docker0", "docker0", 4, false, "172.17.0.1"); + NetworkInterfaceInfo lan = + iface("eth0", "Intel(R) Ethernet Connection", 2, false, "10.0.0.5"); + + assertEquals("10.0.0.5", GeneralUtils.selectBestSiteLocalIp(List.of(wsl, docker, lan))); + } + + @Test + void prefers192Over10WhenBothPhysical() { + NetworkInterfaceInfo ten = iface("eth0", "Ethernet", 2, false, "10.1.2.3"); + NetworkInterfaceInfo home = iface("wlan0", "Wi-Fi", 6, false, "192.168.1.10"); + + assertEquals("192.168.1.10", GeneralUtils.selectBestSiteLocalIp(List.of(ten, home))); + } + + @Test + void breaksTiesByLowestInterfaceIndex() { + NetworkInterfaceInfo first = iface("eth0", "Ethernet", 2, false, "192.168.1.2"); + NetworkInterfaceInfo second = iface("eth1", "Ethernet", 9, false, "192.168.1.3"); + + assertEquals("192.168.1.2", GeneralUtils.selectBestSiteLocalIp(List.of(second, first))); + } + + @Test + void returnsNullWhenOnlyVirtualOrDownInterfaces() { + NetworkInterfaceInfo vbox = + iface("vboxnet0", "VirtualBox Host-Only Network", 1, false, "192.168.56.1"); + NetworkInterfaceInfo flaggedVirtual = + new NetworkInterfaceInfo( + "eth9", + "Ethernet", + 9, + true, + false, + false, + true, + true, + List.of("192.168.1.9")); + NetworkInterfaceInfo down = + new NetworkInterfaceInfo( + "eth0", + "Ethernet", + 2, + false, + false, + false, + false, + true, + List.of("192.168.1.2")); + + assertNull(GeneralUtils.selectBestSiteLocalIp(List.of(vbox, flaggedVirtual, down))); + } + + @Test + void isLikelyVirtualInterfaceFlagsKnownAdaptersButNotRealNics() { + assertTrue( + GeneralUtils.isLikelyVirtualInterface( + "vEthernet", "Hyper-V Virtual Ethernet Adapter")); + assertTrue(GeneralUtils.isLikelyVirtualInterface("docker0", "docker0")); + assertTrue( + GeneralUtils.isLikelyVirtualInterface("eth0", "VMware Virtual Ethernet Adapter")); + assertTrue(GeneralUtils.isLikelyVirtualInterface("tun0", "WireGuard tunnel")); + + assertFalse(GeneralUtils.isLikelyVirtualInterface("wlan0", "Intel(R) Wi-Fi 6 AX201")); + assertFalse( + GeneralUtils.isLikelyVirtualInterface( + "eth0", "Realtek PCIe GbE Family Controller")); + } +} diff --git a/app/core/build.gradle b/app/core/build.gradle index e505ec9838..21acdb36e0 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -332,8 +332,8 @@ tasks.register('cleanFrontendAssets', Delete) { delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) } // Prerendered per-route SPA pages (e.g. compress.html) carry per-tool OG tags and are // copied from the frontend build. Remove stale ones so renamed/removed tools don't linger. - // api-landing.html is a real backend source file, not a generated artifact. - delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html']) + // api-landing.html and mobile-upload.html are real backend source files, not generated artifacts. + delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html', 'mobile-upload.html']) // Nested prerendered route pages (e.g. settings/people.html) delete new File(resourcesStaticDir, 'settings') } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java index 62ce9dac01..1e05ec17b2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -42,6 +42,8 @@ public class ReactRoutingController { private boolean loggedMissingIndex = false; private String cachedSaasLandingHtml; private boolean saasLandingExists = false; + private String cachedMobileUploadHtml; + private boolean mobileUploadHtmlExists = false; @PostConstruct public void init() { @@ -64,6 +66,12 @@ public class ReactRoutingController { } } + // Desktop (Tauri) serves the SPA from its bundled webview, so a phone scanning the QR can't + // load the React /mobile-scanner route from the local backend. Cache the self-contained + // static upload page to serve at that route in desktop mode instead. + this.cachedMobileUploadHtml = readStaticHtml("mobile-upload.html"); + this.mobileUploadHtmlExists = this.cachedMobileUploadHtml != null; + // Check for external index.html first (customFiles/static/) Path externalIndexPath = Path.of(InstallationPathConfig.getStaticPath(), "index.html"); log.debug("Checking for custom index.html at: {}", externalIndexPath); @@ -144,6 +152,28 @@ public class ReactRoutingController { return new ClassPathResource("static/index.html"); } + private String readStaticHtml(String filename) { + try { + Path external = Path.of(InstallationPathConfig.getStaticPath(), filename); + if (Files.exists(external) && Files.isReadable(external)) { + return Files.readString(external, StandardCharsets.UTF_8); + } + ClassPathResource resource = new ClassPathResource("static/" + filename); + if (resource.exists()) { + try (InputStream in = resource.getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + } catch (Exception ex) { + log.warn("Failed to read static HTML {}", filename, ex); + } + return null; + } + + private static boolean isDesktopMode() { + return Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false")); + } + @GetMapping( value = {"/", "/index.html"}, produces = MediaType.TEXT_HTML_VALUE) @@ -191,6 +221,17 @@ public class ReactRoutingController { return serveIndexHtml(request); } + @GetMapping(value = "/mobile-scanner", produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity serveMobileScanner(HttpServletRequest request) { + if (isDesktopMode() && mobileUploadHtmlExists) { + return ResponseEntity.ok() + .cacheControl(CacheControl.noCache().mustRevalidate()) + .contentType(MediaType.TEXT_HTML) + .body(cachedMobileUploadHtml); + } + return serveIndexHtml(request); + } + @GetMapping(value = "/auth/callback/tauri", produces = MediaType.TEXT_HTML_VALUE) public ResponseEntity serveTauriAuthCallback(HttpServletRequest request) { // cachedCallbackHtml is always initialized in @PostConstruct diff --git a/app/core/src/main/resources/static/mobile-upload.html b/app/core/src/main/resources/static/mobile-upload.html new file mode 100644 index 0000000000..f96f9f4f53 --- /dev/null +++ b/app/core/src/main/resources/static/mobile-upload.html @@ -0,0 +1,572 @@ + + + + + + + + Stirling PDF - Mobile Upload + + + + + + + + +
+
+ +
+ + + + +
Mobile Upload
+
+
+ +
+
Connecting…
+ +
+ + +
+ + + + +
+ + + + + +

Add photos or files, then upload. They appear on your computer automatically.

+
+ + + +
Stirling PDF · files transfer directly to your desktop
+
+ + + + diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java index d1305ff763..2df3fb5587 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java @@ -95,6 +95,38 @@ class ReactRoutingControllerTest { assertTrue(body.contains("Stirling PDF")); } + // --- mobile scanner route --- + + @Test + void serveMobileScanner_webMode_servesSpaNotUploadPage() { + controller.init(); + + ResponseEntity response = controller.serveMobileScanner(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + String body = response.getBody(); + assertNotNull(body); + assertFalse(body.contains("Take Photo")); + } + + @Test + void serveMobileScanner_desktopMode_servesStaticUploadPage() { + controller.init(); + System.setProperty("STIRLING_PDF_TAURI_MODE", "true"); + try { + ResponseEntity response = controller.serveMobileScanner(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(MediaType.TEXT_HTML, response.getHeaders().getContentType()); + String body = response.getBody(); + assertNotNull(body); + assertTrue(body.contains("Mobile Upload")); + assertTrue(body.contains("Take Photo")); + } finally { + System.clearProperty("STIRLING_PDF_TAURI_MODE"); + } + } + // --- tauri auth callback --- @Test