Skip to content

Commit 34819ae

Browse files
authored
Draw signatures on a phone via QR code in the Sign tool (#7335)
# Description of Changes Scan a QR code in the Sign tool, draw your signature on your phone, and it appears on your desktop ready to place. Rides the mobile scanner's existing transfer sessions — no new backend endpoints. **Desktop:** a **Mobile upload** button above the signature source selector shows a QR code. When the signature arrives, the modal closes, it lands in the matching source, and **placement activates automatically** — click the PDF to place. **Phone:** a new public `/mobile-sign` page with three tabs (same order as the desktop sources): - **Draw** → canvas signature. Touch-first pad (pointer events, DPR-aware, smoothed strokes, undo/clear, black/blue ink, 3 pen sizes), exported as a transparent PNG cropped to the ink. Compact layout in phone landscape. - **Photo** → image signature. "Take a photo" opens the camera directly; "From gallery" opens the picker. A preview of the current image signature now shows in the desktop's Image source (previously arrival was invisible until placement — also fixes this for saved image signatures). - **Type** → text signature. Travels as data (text + font + colour), so it stays *editable* on the desktop. Fonts are the sign tool's own text-mode list. **Security:** the transfer endpoints are unauthenticated by design (10-min sessions, files deleted after download — same model as the scanner). The desktop treats every arrival as untrusted: images only, and the text payload is clamped field by field. **Config:** new `system.enableMobileSignature` flag (default on), independent of `enableMobileScanner`; the shared endpoints accept either. The Tauri desktop app serves a self-contained `mobile-sign.html` (draw-only), mirroring `mobile-upload.html`. **Refactor:** the session lifecycle (create/poll/download/expiry) moved out of `MobileUploadModal` into a shared `useMobileTransferSession` hook; the scanner modal now uses it, behaviour unchanged. Also fixes two bugs hit along the way: the signature pad collapsing to its 150px intrinsic height (indefinite parent height), and a setState loop in `SignSettings` when text parameters are set programmatically (draft-sync effects ping-ponging). ## Screenshots | Desktop: QR entry | Phone: draw | Desktop: received | |---|---|---| | ![QR modal](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-7335-assets/shot-1-qr-modal.png) | ![Phone draw tab](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-7335-assets/shot-2-phone-draw.png) | ![Signature received](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-7335-assets/shot-3-desktop-received.png) | ## How to test 1. Open the app on an address your phone can reach (not `localhost`), Sign tool → **Mobile upload**, scan the QR. 2. Draw → **Send to computer** → it becomes the active canvas signature and placement is live: click the PDF to place. 3. Photo tab → arrives in the Image source with a preview. Type tab → arrives editable in the Text source. 4. Flags: `enableMobileSignature: false` hides the button; signature still works with the scanner disabled. Verified end-to-end (all three kinds, portrait/landscape/tablet) plus `task frontend:check` and the touched backend tests. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] 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) - [x] I have performed a self-review of my own code - [x] 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) - [x] 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) - [x] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have run `task check` to verify linters, typechecks, and tests pass - [x] 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.
1 parent 35ac74b commit 34819ae

27 files changed

Lines changed: 2822 additions & 422 deletions

File tree

‎app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,6 +1046,8 @@ public static class System {
10461046

10471047
// 'https://app.example.com'). If not set, falls back to backendUrl.
10481048
private boolean enableMobileScanner = true; // Enable mobile phone QR code upload feature
1049+
private boolean enableMobileSignature =
1050+
true; // Enable drawing signatures on a phone via QR code
10491051
private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings();
10501052
private ServerCertificate serverCertificate = new ServerCertificate();
10511053

‎app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,10 @@ public static boolean isStaticResource(String contextPath, String requestURI) {
5656
return true;
5757
}
5858

59-
// Mobile scanner page for QR code-based file uploads (peer-to-peer, no backend auth needed)
60-
if (normalizedUri.startsWith("/mobile-scanner")) {
59+
// Mobile pages reached by scanning a QR code (peer-to-peer, no backend auth
60+
// needed): /mobile-scanner uploads photos, /mobile-sign draws a signature.
61+
if (normalizedUri.startsWith("/mobile-scanner")
62+
|| normalizedUri.startsWith("/mobile-sign")) {
6163
return true;
6264
}
6365

‎app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ void testIsStaticResource_mobileScannerPath() {
7373
assertTrue(RequestUriUtils.isStaticResource("/mobile-scanner"));
7474
}
7575

76+
@Test
77+
void testIsStaticResource_mobileSignPath() {
78+
// The phone-side signature drawing page, reached from the Sign tool QR code.
79+
assertTrue(RequestUriUtils.isStaticResource("/mobile-sign"));
80+
assertTrue(RequestUriUtils.isStaticResource("/app", "/app/mobile-sign"));
81+
}
82+
7683
@Test
7784
void testIsStaticResource_portalShell() {
7885
// The admin portal SPA shell (/processor) is served pre-auth so it's directly navigable.

‎app/core/build.gradle‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,8 +312,9 @@ tasks.register('cleanFrontendAssets', Delete) {
312312
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
313313
// Prerendered per-route SPA pages (e.g. compress.html) carry per-tool OG tags and are
314314
// copied from the frontend build. Remove stale ones so renamed/removed tools don't linger.
315-
// api-landing.html and mobile-upload.html are real backend source files, not generated artifacts.
316-
delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html', 'mobile-upload.html'])
315+
// api-landing.html, mobile-upload.html and mobile-sign.html are real backend source files,
316+
// not generated artifacts.
317+
delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html', 'mobile-upload.html', 'mobile-sign.html'])
317318
// Nested prerendered route pages (e.g. settings/people.html)
318319
delete new File(resourcesStaticDir, 'settings')
319320
}

‎app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,9 @@ public ResponseEntity<Map<String, Object>> getAppConfig(HttpServletRequest reque
194194
configData.put(
195195
"enableMobileScanner",
196196
applicationProperties.getSystem().isEnableMobileScanner());
197+
configData.put(
198+
"enableMobileSignature",
199+
applicationProperties.getSystem().isEnableMobileSignature());
197200
configData.put(
198201
"mobileScannerConvertToPdf",
199202
applicationProperties.getSystem().getMobileScannerSettings().isConvertToPdf());

‎app/core/src/main/java/stirling/software/SPDF/controller/api/misc/MobileScannerController.java‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,17 @@ public MobileScannerController(
6262
}
6363

6464
/**
65-
* Check if mobile scanner feature is enabled
65+
* Check if any feature backed by these transfer sessions is enabled. The mobile scanner and
66+
* mobile signature drawing share this session/upload API, so the endpoints stay available while
67+
* either feature is on; each flag independently controls only its own UI.
6668
*
6769
* @return Error response if disabled, null if enabled
6870
*/
6971
private ResponseEntity<Map<String, Object>> checkFeatureEnabled() {
70-
if (!applicationProperties.getSystem().isEnableMobileScanner()) {
72+
boolean anyEnabled =
73+
applicationProperties.getSystem().isEnableMobileScanner()
74+
|| applicationProperties.getSystem().isEnableMobileSignature();
75+
if (!anyEnabled) {
7176
return ResponseEntity.status(HttpStatus.FORBIDDEN)
7277
.body(
7378
Map.of(
@@ -275,7 +280,8 @@ public ResponseEntity<Resource> downloadFile(
275280
@Parameter(description = "Filename to download", required = true) @PathVariable
276281
String filename) {
277282

278-
if (!applicationProperties.getSystem().isEnableMobileScanner()) {
283+
if (!applicationProperties.getSystem().isEnableMobileScanner()
284+
&& !applicationProperties.getSystem().isEnableMobileSignature()) {
279285
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
280286
}
281287

‎app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ public class ReactRoutingController {
8080
private boolean saasLandingExists = false;
8181
private String cachedMobileUploadHtml;
8282
private boolean mobileUploadHtmlExists = false;
83+
private String cachedMobileSignHtml;
84+
private boolean mobileSignHtmlExists = false;
8385

8486
@PostConstruct
8587
public void init() {
@@ -103,10 +105,12 @@ public void init() {
103105
}
104106

105107
// Desktop (Tauri) serves the SPA from its bundled webview, so a phone scanning the QR can't
106-
// load the React /mobile-scanner route from the local backend. Cache the self-contained
107-
// static upload page to serve at that route in desktop mode instead.
108+
// load the React /mobile-scanner or /mobile-sign routes from the local backend. Cache the
109+
// self-contained static pages to serve at those routes in desktop mode instead.
108110
this.cachedMobileUploadHtml = readStaticHtml("mobile-upload.html");
109111
this.mobileUploadHtmlExists = this.cachedMobileUploadHtml != null;
112+
this.cachedMobileSignHtml = readStaticHtml("mobile-sign.html");
113+
this.mobileSignHtmlExists = this.cachedMobileSignHtml != null;
110114

111115
// Check for external index.html first (customFiles/static/)
112116
Path externalIndexPath = Path.of(InstallationPathConfig.getStaticPath(), "index.html");
@@ -268,6 +272,17 @@ public ResponseEntity<String> serveMobileScanner(HttpServletRequest request) {
268272
return serveIndexHtml(request);
269273
}
270274

275+
@GetMapping(value = "/mobile-sign", produces = MediaType.TEXT_HTML_VALUE)
276+
public ResponseEntity<String> serveMobileSign(HttpServletRequest request) {
277+
if (isDesktopMode() && mobileSignHtmlExists) {
278+
return ResponseEntity.ok()
279+
.cacheControl(CacheControl.noCache().mustRevalidate())
280+
.contentType(MediaType.TEXT_HTML)
281+
.body(cachedMobileSignHtml);
282+
}
283+
return serveIndexHtml(request);
284+
}
285+
271286
@GetMapping(value = "/auth/callback/tauri", produces = MediaType.TEXT_HTML_VALUE)
272287
public ResponseEntity<String> serveTauriAuthCallback(HttpServletRequest request) {
273288
// cachedCallbackHtml is always initialized in @PostConstruct

‎app/core/src/main/resources/settings.yml.template‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ system:
188188
backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
189189
frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
190190
enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
191+
enableMobileSignature: true # Enable drawing signatures on a phone via QR code from the Sign tool. Requires frontendUrl to be configured.
191192
mobileScannerSettings:
192193
convertToPdf: true # Automatically convert uploaded images to PDF format. If false, images are kept as-is.
193194
imageResolution: full # Image resolution for mobile uploads: 'full' (original size) or 'reduced' (max 1200px on longest side). Only applies when convertToPdf is true.

0 commit comments

Comments
 (0)