mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
+41
@@ -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
+32
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user