Add desktop mobile-upload page and fix LAN QR URL (#6736)

# Description of Changes

Desktop can not use QR code upload due to API backend not having UI for
it...
Because of this we add UI, has to be custom because can not support
OpenCV and in app camera due to its https requirement

<img width="919" height="2048" alt="image"
src="https://github.com/user-attachments/assets/d84c3903-fd23-421a-8919-24d89ba6c753"
/>
<img width="919" height="2048" alt="image"
src="https://github.com/user-attachments/assets/d6c8fdf3-837b-4dc3-92ea-37f7502f8462"
/>
<img width="919" height="2048" alt="image"
src="https://github.com/user-attachments/assets/fd4ce3b4-69ad-45dd-b066-bff2d082c583"
/>

<img width="1550" height="790" alt="image"
src="https://github.com/user-attachments/assets/701d4dcc-ebd6-4e03-aa7b-2d8623525fc9"
/>

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
This commit is contained in:
Anthony Stirling
2026-06-19 15:33:49 +00:00
committed by GitHub
parent b9ea9064c7
commit 3870ac3d7d
7 changed files with 933 additions and 15 deletions
+1
View File
@@ -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/
@@ -1185,23 +1185,181 @@ public class GeneralUtils {
}
public String getLocalNetworkIp() {
String routed = detectLocalIpViaDefaultRoute();
if (routed != null) {
return routed;
}
try {
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
if (interfaces == null) return null;
while (interfaces.hasMoreElements()) {
NetworkInterface iface = interfaces.nextElement();
if (!iface.isUp() || iface.isLoopback() || iface.isVirtual()) continue;
Enumeration<InetAddress> 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<NetworkInterfaceInfo> collectInterfaceInfo() throws SocketException {
List<NetworkInterfaceInfo> infos = new ArrayList<>();
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
if (interfaces == null) {
return infos;
}
while (interfaces.hasMoreElements()) {
NetworkInterface iface = interfaces.nextElement();
List<String> siteLocalIpv4s = new ArrayList<>();
Enumeration<InetAddress> 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<NetworkInterfaceInfo> 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<String> siteLocalIpv4s) {}
private record ScoredAddress(String ip, int score, int interfaceIndex) {}
}
@@ -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"));
}
}
+2 -2
View File
@@ -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')
}
@@ -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<String> 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<String> serveTauriAuthCallback(HttpServletRequest request) {
// cachedCallbackHtml is always initialized in @PostConstruct
File diff suppressed because one or more lines are too long
@@ -95,6 +95,38 @@ class ReactRoutingControllerTest {
assertTrue(body.contains("Stirling PDF"));
}
// --- mobile scanner route ---
@Test
void serveMobileScanner_webMode_servesSpaNotUploadPage() {
controller.init();
ResponseEntity<String> 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<String> 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