diff --git a/codemagic.yaml b/codemagic.yaml index 18608c5..f289256 100644 --- a/codemagic.yaml +++ b/codemagic.yaml @@ -54,7 +54,7 @@ workflows: echo "iOS project layout:"; ls -la ios/App # ── Diagnose local-plugin SPM wiring (the regression: our file: plugins didn't function at runtime) ── echo "=== local plugins in node_modules — symlink vs copy, and is Package.swift present? ===" - for p in native-call media-library audio-route share-inbox; do + for p in native-call media-library audio-route share-inbox file-opener; do echo "-- $p --"; ls -ld "node_modules/$p" 2>/dev/null || echo " (dir missing)" ( ls "node_modules/$p/Package.swift" >/dev/null 2>&1 && echo " Package.swift PRESENT" ) || echo " Package.swift MISSING" done diff --git a/mobile/package.json b/mobile/package.json index 8154014..cea1aff 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "audio-route": "file:plugins/audio-route", + "file-opener": "file:plugins/file-opener", "media-library": "file:plugins/media-library", "native-call": "file:plugins/native-call", "share-inbox": "file:plugins/share-inbox", diff --git a/mobile/plugins/file-opener/FileOpener.podspec b/mobile/plugins/file-opener/FileOpener.podspec new file mode 100644 index 0000000..ac764c7 --- /dev/null +++ b/mobile/plugins/file-opener/FileOpener.podspec @@ -0,0 +1,22 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) + +Pod::Spec.new do |s| + # NOTE: the pod name MUST be 'FileOpener' (PascalCase of the npm package name 'file-opener'). + # `cap sync` writes `pod 'FileOpener', :path => '../../plugins/file-opener'` into the generated Podfile, + # and CocoaPods then looks for a file literally named FileOpener.podspec whose s.name is 'FileOpener'. + # Any other name → "No podspec found for `FileOpener`" and pod install fails. (Same trap that broke the + # AudioRoute build — see mobile/plugins/audio-route/AudioRoute.podspec.) + s.name = 'FileOpener' + s.version = package['version'] + s.summary = package['description'] + s.license = package['license'] + s.homepage = 'https://bizgaze.com' + s.author = 'BizGaze' + s.source = { :git => 'https://bizgaze.com/file-opener.git', :tag => s.version.to_s } + s.source_files = 'ios/Sources/**/*.{swift,h,m}' + s.ios.deployment_target = '15.0' + s.dependency 'Capacitor' + s.swift_version = '5.1' +end diff --git a/mobile/plugins/file-opener/Package.swift b/mobile/plugins/file-opener/Package.swift new file mode 100644 index 0000000..f8ac396 --- /dev/null +++ b/mobile/plugins/file-opener/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "FileOpener", + platforms: [.iOS(.v15)], + products: [ + .library(name: "FileOpener", targets: ["FileOpenerPlugin"]) + ], + dependencies: [ + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0") + ], + targets: [ + .target( + name: "FileOpenerPlugin", + dependencies: [ + .product(name: "Capacitor", package: "capacitor-swift-pm"), + .product(name: "Cordova", package: "capacitor-swift-pm") + ], + path: "ios/Sources/FileOpenerPlugin") + ] +) diff --git a/mobile/plugins/file-opener/ios/Sources/FileOpenerPlugin/FileOpenerPlugin.swift b/mobile/plugins/file-opener/ios/Sources/FileOpenerPlugin/FileOpenerPlugin.swift new file mode 100644 index 0000000..17180a7 --- /dev/null +++ b/mobile/plugins/file-opener/ios/Sources/FileOpenerPlugin/FileOpenerPlugin.swift @@ -0,0 +1,71 @@ +import Foundation +import Capacitor +import QuickLook + +// Previews an already-downloaded file with iOS Quick Look — the native "look at this file" surface: swipe, +// pinch-zoom, print, and its own share button. Registered by cap sync as window.Capacitor.Plugins.FileOpener. +// +// WHY A PLUGIN: the app loads its UI from a REMOTE origin (remote.bizgaze.com), so it cannot open a local +// file:// URL in the WebView (cross-origin / capacitor local-serving isn't on this origin). @capacitor/share +// only offers the share SHEET ("open in another app"), not a preview. Only QLPreviewController, presented +// from native code, gives a real in-app preview. Quick Look picks the renderer from the file extension, which +// is why the web layer now saves downloads with a correct extension (see bzEnsureExt in home.html). +@objc(FileOpenerPlugin) +public class FileOpenerPlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "FileOpenerPlugin" + public let jsName = "FileOpener" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "preview", returnType: CAPPluginReturnPromise) + ] + + // QLPreviewController holds its dataSource weakly, so we must keep a strong reference alive for the + // lifetime of the presented preview — otherwise it deallocates and the preview shows blank. + private var dataSource: QLDataSource? + + @objc func preview(_ call: CAPPluginCall) { + guard let raw = call.getString("path"), !raw.isEmpty else { + call.reject("path is required"); return + } + let url = FileOpenerPlugin.fileURL(from: raw) + guard FileManager.default.fileExists(atPath: url.path) else { + call.reject("file not found: \(url.path)"); return + } + DispatchQueue.main.async { + let ds = QLDataSource(url: url) + self.dataSource = ds + let controller = QLPreviewController() + controller.dataSource = ds + controller.modalPresentationStyle = .fullScreen + guard let base = self.bridge?.viewController else { + call.reject("no view controller to present from"); return + } + // Present on top of whatever is already showing (a modal, another sheet) so it never fails silently. + var presenter = base + while let top = presenter.presentedViewController { presenter = top } + presenter.present(controller, animated: true) { + call.resolve(["ok": true]) + } + } + } + + // Accepts either a file:// URI (what Filesystem.writeFile returns) or a bare absolute path. + private static func fileURL(from raw: String) -> URL { + if raw.hasPrefix("file://") { + if let u = URL(string: raw) { return u } + // Un-encoded spaces make URL(string:) fail — percent-encode and retry before giving up. + let encoded = raw.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? raw + if let u = URL(string: encoded) { return u } + } + return URL(fileURLWithPath: raw) + } +} + +// Single-item data source. NSURL already conforms to QLPreviewItem. +final class QLDataSource: NSObject, QLPreviewControllerDataSource { + private let url: URL + init(url: URL) { self.url = url } + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 } + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + return url as NSURL + } +} diff --git a/mobile/plugins/file-opener/package.json b/mobile/plugins/file-opener/package.json new file mode 100644 index 0000000..d123655 --- /dev/null +++ b/mobile/plugins/file-opener/package.json @@ -0,0 +1,27 @@ +{ + "name": "file-opener", + "version": "1.0.0", + "description": "Preview a downloaded file with native iOS Quick Look for Biz Connect", + "main": "dist/plugin.cjs.js", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "author": "BizGaze", + "license": "MIT", + "files": [ + "dist/", + "ios/", + "FileOpener.podspec", + "Package.swift" + ], + "capacitor": { + "ios": { + "src": "ios" + } + }, + "devDependencies": { + "@capacitor/core": "^8.0.0" + }, + "peerDependencies": { + "@capacitor/core": "^8.0.0" + } +} diff --git a/server/public/home.html b/server/public/home.html index d3c27ab..ce7dfba 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -2809,6 +2809,23 @@ function bzMimeFromName(n){ if(/^(mp3|wav|m4a|aac|ogg|opus)$/.test(e)) return 'audio/'+e; return ''; } +// Map a MIME type to a file extension. Needed because some downloads arrive without one — the lightbox +// hands us a bare "/files/" (no name, no extension) — and a file saved with no extension is one that +// neither the Photos app will accept nor the Files app / Quick Look can open. +function bzExtForMime(mime){ + const m=String(mime||'').toLowerCase().split(';')[0].trim(); + const map={'image/jpeg':'jpg','image/jpg':'jpg','image/png':'png','image/gif':'gif','image/webp':'webp','image/heic':'heic','image/heif':'heif','image/bmp':'bmp','image/svg+xml':'svg', + 'video/mp4':'mp4','video/quicktime':'mov','video/webm':'webm','video/3gpp':'3gp','video/x-matroska':'mkv','video/x-msvideo':'avi', + 'audio/mpeg':'mp3','audio/mp4':'m4a','audio/aac':'aac','audio/wav':'wav','audio/x-wav':'wav','audio/ogg':'ogg','audio/opus':'opus', + 'application/pdf':'pdf','application/zip':'zip','text/plain':'txt'}; + if(map[m]) return map[m]; + return (m.split('/')[1]||'').replace(/\+.*$/,'').replace(/[^a-z0-9]+/g,'').slice(0,5); // e.g. "svg+xml"→"svg" +} +function bzEnsureExt(name, mime){ + const n=String(name||'file'); + if(/\.[a-z0-9]{1,6}$/i.test(n)) return n; // already carries an extension + const ext=bzExtForMime(mime); return ext ? n+'.'+ext : n; +} function bzLibSrc(r){ try{ return r&&r.uri ? window.Capacitor.convertFileSrc(r.uri) : null; }catch(_){ return null; } } function bzPhotosOn(){ try{ return localStorage.getItem('bzc.photos')!=='off'; }catch(_){ return true; } } // Also put saved photos/videos in the Photos app, in a "Connect" album — the way WhatsApp does. The Files @@ -2889,11 +2906,14 @@ function bzSyncFileTiles(only){ el.title = saved ? 'Open' : 'Download to this device'; }); } -// Open an already-downloaded file via the iOS share/preview sheet (Quick Look / open in another app). We have -// no dedicated file-opener plugin, so @capacitor/share on the local file URI is the reliable "open". +// Open an already-downloaded file. Prefer a native Quick Look PREVIEW (the FileOpener plugin) — that's the iOS +// "look at this file" surface (swipe, pinch, print, its own share button), which is what people expect when +// they tap a file. Only if the preview plugin isn't in this build do we fall back to the share sheet (which +// offers "open in another app"), then to a new browser tab. async function bzOpenFile(rec, name){ const uri=rec&&rec.uri; if(!uri) return; const P=window.Capacitor&&window.Capacitor.Plugins; + if(P&&P.FileOpener&&P.FileOpener.preview){ try{ await P.FileOpener.preview({ path:uri }); return; }catch(_){ /* fall through to the share sheet */ } } if(P&&P.Share){ try{ await P.Share.share({ title:name||'File', url:uri }); }catch(_){} return; } // catch = user cancelled try{ const src=bzLibSrc(rec); if(src) window.open(src, '_blank'); }catch(_){} } @@ -2901,13 +2921,19 @@ async function nativeSaveFile(url, name, meta){ const C=window.Capacitor, P=C&&C.Plugins; const Fs=P&&P.Filesystem; if(!Fs) return false; const id=(meta&&meta.id) || (String(url).split('?')[0].split('/').pop()||''); - const fname=(String(name||url.split('/').pop()||'file').replace(/[\/\\:*?"<>|\r\n]+/g,'_')).slice(0,120)||'file'; - const mime=(meta&&meta.mime)||bzMimeFromName(fname); + let fname=(String(name||url.split('/').pop()||'file').replace(/[\/\\:*?"<>|\r\n]+/g,'_')).slice(0,120)||'file'; + let mime=(meta&&meta.mime)||bzMimeFromName(fname); const onPct=(meta&&meta.onPct)||null; const box=onPct?null:bzDlShow(fname); // a video reports % inside its own tile, so no floating chip - const lpath=bzLibPath(fname, mime, id); try{ const res=await fetch(url, {credentials:'include'}); if(!res.ok) throw new Error('http '+res.status); + // The lightbox / avatar downloads hand us a bare "/files/" — no name, no extension, no mime — so an + // image was landing in the generic "Files" folder as an extension-less blob and never reaching Photos. + // Trust the server's Content-Type here, then give the file a real extension, so it goes to the right + // folder, opens in Quick Look, AND qualifies for the Photos save below. + if(!mime){ mime=((res.headers.get('Content-Type')||'').split(';')[0]||'').trim().toLowerCase(); } + fname=bzEnsureExt(fname, mime); + const lpath=bzLibPath(fname, mime, id); const total=+(res.headers.get('Content-Length')||0); let uri=null, wrote=0; if(res.body && res.body.getReader){