Spaces:
Running
Running
Download capacitor_plugins.py from ShadowHunter222/qw: direct link, hf CLI and curl.
- Browser
- Download file 34.4 kB
-
https://huggingface.co/spaces/ShadowHunter222/qw/resolve/main/capacitor_plugins.py
- Command line
-
hf download hf://spaces/ShadowHunter222/qw/capacitor_plugins.py
-
curl -L -o capacitor_plugins.py https://huggingface.co/spaces/ShadowHunter222/qw/resolve/main/capacitor_plugins.py
34.4 kB
| """Capacitor device plugins — native file/folder pick + save for web/Vite apps. | |
| Pure helper module (no FastAPI import). Imported lazily by backend/app.py so | |
| unit/test harnesses can import without a server. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import re | |
| from pathlib import Path | |
| # Pinned per Capacitor major (major follows Capacitor major; --no-save install). | |
| # Verified via npm/capawesome docs: file-picker 8.x needs Cap >=8 (6.x/5.x | |
| # deprecated); filesystem 8.1.x is current Cap8; share 8.x is Cap8; file-sharer | |
| # v8 tracks Cap8; scoped-storage 0.1.x is MIT SAF/bookmarks (70/week — newest, | |
| # kept optional-best-effort, never fatal). | |
| DEVICE_PLUGIN_PINS: dict[int, dict[str, str]] = { | |
| 8: { | |
| "@capawesome/capacitor-file-picker": "8", | |
| "@capacitor/filesystem": "8", | |
| "@capacitor/share": "8", | |
| "@capacitor/file-transfer": "latest", | |
| "@capgo/capacitor-file-sharer": "8", | |
| "@daniele-rolli/capacitor-scoped-storage": "latest", | |
| "@capacitor-community/media": "9", | |
| }, | |
| 7: { | |
| "@capawesome/capacitor-file-picker": "7", | |
| "@capacitor/filesystem": "7", | |
| "@capacitor/share": "7", | |
| "@capacitor/file-transfer": "latest", | |
| "@capgo/capacitor-file-sharer": "7", | |
| "@daniele-rolli/capacitor-scoped-storage": "latest", | |
| "@capacitor-community/media": "8", | |
| }, | |
| 6: { | |
| "@capawesome/capacitor-file-picker": "6", | |
| "@capacitor/filesystem": "6", | |
| "@capacitor/share": "6", | |
| "@capacitor/file-transfer": "latest", | |
| "@capgo/capacitor-file-sharer": "6", | |
| "@daniele-rolli/capacitor-scoped-storage": "latest", | |
| "@capacitor-community/media": "7", | |
| }, | |
| } | |
| # Core set installed by the builder (media is gallery-only; installed on demand | |
| # when the web bundle references it, to keep default builds lean). | |
| CORE_DEVICE_PLUGINS = [ | |
| "@capawesome/capacitor-file-picker", | |
| "@capacitor/filesystem", | |
| "@capacitor/share", | |
| "@capacitor/file-transfer", | |
| "@capgo/capacitor-file-sharer", | |
| "@daniele-rolli/capacitor-scoped-storage", | |
| ] | |
| DEVICE_FILES_JS = """/* device-files.js — auto native pick/save bridge (builder-injected, idempotent). | |
| * No bundler needed. Browser passthrough when not native. React-SPA safe via | |
| * MutationObserver + DataTransfer backfill (React onChange fires on change). | |
| * Opt out per element: data-device-native="off". | |
| * Programmatic: window.DeviceFiles.pickFolder()/pickFiles()/saveFile(blob,name). | |
| */ | |
| (function () { | |
| if (window.DeviceFiles && window.DeviceFiles.__wired) return; | |
| var STORE_KEY = "devicefiles.folder.v1"; | |
| function plugins() { return (window.Capacitor && window.Capacitor.Plugins) || {}; } | |
| function isNative() { | |
| try { | |
| if (window.Capacitor && typeof window.Capacitor.isNativePlatform === "function") | |
| return !!window.Capacitor.isNativePlatform(); | |
| return !!(window.Capacitor && window.Capacitor.isNative); | |
| } catch (e) { return false; } | |
| } | |
| // Resolve a plugin by bridge name. Path 1: pre-registered proxy (bundled | |
| // web bundle or app import). Path 2: on-demand native proxy via the public | |
| // registerPlugin core API — covers foreign bundles that never imported the | |
| // plugin package. Returns {ref, via} or null. Never throws. | |
| function resolvePlugin(name) { | |
| try { | |
| var P = plugins(); | |
| if (P[name]) return { ref: P[name], via: "registered" }; | |
| if (window.Capacitor && typeof window.Capacitor.registerPlugin === "function") { | |
| try { | |
| var r = window.Capacitor.registerPlugin(name); | |
| if (r) return { ref: r, via: "registerPlugin" }; | |
| } catch (e) {} | |
| } | |
| } catch (e) {} | |
| return null; | |
| } | |
| function pluginAvailable(name) { | |
| try { | |
| if (window.Capacitor && typeof window.Capacitor.isPluginAvailable === "function") { | |
| try { return !!window.Capacitor.isPluginAvailable(name); } catch (e) {} | |
| } | |
| } catch (e) {} | |
| return null; // unknown (old runtime) | |
| } | |
| function statusSnapshot() { | |
| var names = ["FilePicker", "Filesystem", "Share", "FileTransfer", "FileSharer", "ScopedStorage", "Media"]; | |
| var out = {}; | |
| for (var i = 0; i < names.length; i++) { | |
| try { | |
| var hit = resolvePlugin(names[i]); | |
| out[names[i]] = hit ? hit.via : ("available:" + String(pluginAvailable(names[i]))); | |
| } catch (e) { out[names[i]] = "error"; } | |
| } | |
| var capV = null; | |
| try { capV = (window.Capacitor && (window.Capacitor.platform || null)); } catch (e) {} | |
| return { native: isNative(), platform: capV, plugins: out, lastPick: api.lastPick || null }; | |
| } | |
| function optedOut(el) { | |
| try { return el && typeof el.getAttribute === "function" && el.getAttribute("data-device-native") === "off"; } catch (e) { return false; } | |
| } | |
| function dispatch(el, type, detail) { | |
| try { | |
| var ev; | |
| if (typeof window.CustomEvent === "function") ev = new window.CustomEvent(type, { bubbles: true, detail: detail }); | |
| else if (document.createEvent) { ev = document.createEvent("CustomEvent"); if (ev.initCustomEvent) ev.initCustomEvent(type, true, true, detail); else if (ev.initEvent) ev.initEvent(type, true, true); ev.detail = detail; } | |
| else if (typeof window.Event === "function") ev = new window.Event(type, { bubbles: true }); | |
| else { ev = { type: type, detail: detail }; } | |
| if (!ev.type && type) ev.type = type; | |
| el.dispatchEvent(ev); | |
| } catch (e) { try { el.dispatchEvent({ type: type, detail: detail }); } catch (_) {} } | |
| } | |
| function loadStored() { | |
| try { | |
| var raw = window.localStorage ? window.localStorage.getItem(STORE_KEY) : null; | |
| return raw ? JSON.parse(raw) : null; | |
| } catch (e) { return null; } | |
| } | |
| function storeFolder(f) { | |
| try { if (window.localStorage && f) window.localStorage.setItem(STORE_KEY, JSON.stringify(f)); } catch (e) {} | |
| } | |
| async function scopedFolder() { | |
| var SS = resolvePlugin("ScopedStorage"); | |
| if (!SS || typeof SS.ref.pickFolder !== "function") return null; | |
| var P = { ScopedStorage: SS.ref }; | |
| var stored = loadStored(); | |
| if (stored) { | |
| try { | |
| if (typeof P.ScopedStorage.exists === "function") { | |
| var chk = await P.ScopedStorage.exists({ folder: stored }); | |
| if (chk && chk.exists) return { folder: stored, mode: "native-persistent", restored: true }; | |
| } else { return { folder: stored, mode: "native-persistent", restored: true }; } | |
| } catch (e) {} | |
| } | |
| var res = await P.ScopedStorage.pickFolder(); | |
| var folder = (res && res.folder) || res; | |
| if (folder) storeFolder(folder); | |
| return { folder: folder, mode: "native-persistent", restored: false }; | |
| } | |
| async function pickFolderNative() { | |
| var SS = resolvePlugin("ScopedStorage"); | |
| if (SS && typeof SS.ref.pickFolder === "function") { | |
| var s = await scopedFolder(); | |
| if (s && s.folder) { api.lastPick = { folder: s.folder, mode: s.mode }; return { folder: s.folder, mode: s.mode }; } | |
| } | |
| var FP = resolvePlugin("FilePicker"); | |
| if (FP && typeof FP.ref.pickDirectory === "function") { | |
| var r = await FP.ref.pickDirectory(); | |
| var path = (r && r.path) || (r && r.folder) || r; | |
| api.lastPick = { path: typeof path === "string" ? path : (path && path.id) || String(path), mode: "native" }; | |
| return { path: api.lastPick.path, mode: "native" }; | |
| } | |
| return null; | |
| } | |
| function backfillInput(input, name, blob) { | |
| try { | |
| var dt; | |
| if (typeof window.DataTransfer === "function") { | |
| dt = new window.DataTransfer(); | |
| var f = blob; | |
| if (!(f instanceof window.File) && typeof window.File === "function") { | |
| try { f = new window.File([blob], name || "file", { type: (blob && blob.type) || "" }); } catch (e) { f = blob; } | |
| } | |
| if (dt.items && typeof dt.items.add === "function") dt.items.add(f); | |
| else if (typeof dt.add === "function") dt.add(f); | |
| try { input.files = dt.files; } catch (e) { try { Object.defineProperty(input, "files", { value: dt.files, configurable: true }); } catch (_) {} } | |
| } | |
| } catch (e) {} | |
| } | |
| async function pickFilesNative(opts) { | |
| var FP = resolvePlugin("FilePicker"); | |
| if (!(FP && typeof FP.ref.pickFiles === "function")) return null; | |
| var res = await FP.ref.pickFiles(opts || {}); | |
| var files = (res && res.files) || []; | |
| return { files: files, mode: "native" }; | |
| } | |
| function blobToBase64(blob) { | |
| return new Promise(function (resolve, reject) { | |
| try { | |
| var fr = new window.FileReader(); | |
| fr.onload = function () { | |
| var s = String(fr.result || ""); | |
| var i = s.indexOf("base64,"); | |
| resolve(i >= 0 ? s.slice(i + 7) : s); | |
| }; | |
| fr.onerror = reject; | |
| fr.readAsDataURL(blob); | |
| } catch (e) { reject(e); } | |
| }); | |
| } | |
| async function saveFileNative(blob, name, opts) { | |
| var filename = name || (opts && opts.filename) || "download.bin"; | |
| var ctype = (blob && blob.type) || (opts && opts.contentType) || "application/octet-stream"; | |
| // 1. sharer first: silent MediaStore save, no sheet, no Documents write. | |
| try { | |
| var FSH = resolvePlugin("FileSharer"); | |
| if (FSH && typeof FSH.ref.save === "function") { | |
| var data = null; | |
| try { data = await blobToBase64(blob); } catch (e) { data = null; } | |
| var out = await FSH.ref.save({ filename: filename, contentType: ctype, base64Data: data }); | |
| return { ok: true, mode: "native-silent", uri: out && out.uri }; | |
| } | |
| } catch (e) {} | |
| // 2. silent own-Documents save, still no sheet. | |
| try { | |
| var FSS = resolvePlugin("Filesystem"); | |
| if (isNative() && FSS && typeof FSS.ref.writeFile === "function") { | |
| var b64 = await blobToBase64(blob); | |
| await FSS.ref.writeFile({ path: filename, data: b64, directory: "DOCUMENTS" }); | |
| return { ok: true, mode: "native-silent" }; | |
| } | |
| } catch (e) {} | |
| // 3. web fallback (browser or no native fs). | |
| try { | |
| var url = null; | |
| if (blob) { | |
| if (typeof window.URL !== "undefined" && window.URL.createObjectURL) url = window.URL.createObjectURL(blob); | |
| } | |
| if (url) { | |
| var a = document.createElement("a"); | |
| a.href = url; a.download = filename; | |
| try { document.body.appendChild(a); } catch (e) {} | |
| try { a.click(); } catch (e) {} | |
| try { setTimeout(function () { try { document.body.removeChild(a); } catch (e) {} try { window.URL.revokeObjectURL(url); } catch (e2) {} }, 4000); } catch (e) {} | |
| return { ok: true, mode: "web" }; | |
| } | |
| } catch (e) {} | |
| return { ok: !!blob, mode: "web" }; | |
| } | |
| function enhanceInput(input) { | |
| if (!input || input.__deviceWired || optedOut(input)) return; | |
| input.__deviceWired = true; | |
| var isDir = false; | |
| try { isDir = input.hasAttribute && (input.hasAttribute("webkitdirectory") || input.hasAttribute("webkitDirectory") || input.hasAttribute("directory")); } catch (e) {} | |
| // Also treat folder buttons via data-device-folder elsewhere; here only file inputs. | |
| input.addEventListener("click", function (e) { | |
| if (!isNative() || optedOut(input)) return; | |
| if (isDir) { | |
| var SSD = resolvePlugin("ScopedStorage"); | |
| if (SSD && typeof SSD.ref.pickFolder === "function") { | |
| e.preventDefault(); | |
| scopedFolder().then(function (s) { | |
| if (s && s.folder) { api.lastPick = { folder: s.folder, mode: s.mode }; dispatch(input, "devicefolder", { folder: s.folder }); } | |
| }).catch(function () {}); | |
| return; | |
| } | |
| var FPD = resolvePlugin("FilePicker"); | |
| if (FPD && typeof FPD.ref.pickDirectory === "function") { | |
| e.preventDefault(); | |
| FPD.ref.pickDirectory().then(function (r) { | |
| var path = (r && r.path) || r; | |
| api.lastPick = { path: typeof path === "string" ? path : String(path), mode: "native" }; | |
| dispatch(input, "devicefolder", { path: api.lastPick.path }); | |
| }).catch(function () {}); | |
| return; | |
| } | |
| // Clear-reason deadend: report exactly what is missing so the denial | |
| // is diagnosable (see window.DeviceFiles.status()). Never files dialog. | |
| e.preventDefault(); | |
| var why = "native=" + String(isNative()) + " scoped=" + String(!!(SSD && SSD.ref)) + " picker=" + String(!!(FPD && FPD.ref)); | |
| try { | |
| var P2 = plugins(); | |
| if (P2.Dialog && typeof P2.Dialog.alert === "function") P2.Dialog.alert({ title: "Folder picking", message: "Folder picking is not available in this build (" + why + "). Pick files instead, or rebuild with device plugins." }); | |
| else if (window.alert) window.alert("Folder picking unavailable in this build (" + why + ")."); | |
| } catch (err) {} | |
| try { if (window.console && window.console.warn) window.console.warn("[DeviceFiles] folder deadend: " + why, statusSnapshot()); } catch (err2) {} | |
| return; | |
| } | |
| var FPF = resolvePlugin("FilePicker"); | |
| if (FPF && typeof FPF.ref.pickFiles === "function") { | |
| e.preventDefault(); | |
| FPF.ref.pickFiles().then(function (res) { | |
| var files = (res && res.files) || []; | |
| if (files.length) { | |
| var f0 = files[0]; | |
| var name = f0.name || input.getAttribute("name") || "file"; | |
| var blob = null; | |
| if (f0.blob) blob = f0.blob; | |
| else if (f0.webPath) { | |
| try { | |
| fetch(f0.webPath).then(function (r) { return r.blob(); }).then(function (b) { | |
| backfillInput(input, name, b); | |
| try { input.setAttribute("data-device-picked", "1"); } catch (e2) {} | |
| dispatch(input, "change", { files: files }); | |
| try { dispatch(input, "input", { files: files }); } catch (e3) {} | |
| }).catch(function () { dispatch(input, "change", { files: files }); }); | |
| return; | |
| } catch (err) {} | |
| } else if (f0.path) { | |
| try { | |
| var conv = (window.Capacitor && typeof window.Capacitor.convertFileSrc === "function") ? window.Capacitor.convertFileSrc(f0.path) : f0.path; | |
| fetch(conv).then(function (r) { return r.blob(); }).then(function (b) { | |
| backfillInput(input, name, b); | |
| dispatch(input, "change", { files: files }); | |
| }).catch(function () { dispatch(input, "change", { files: files }); }); | |
| return; | |
| } catch (err2) {} | |
| } | |
| if (blob) backfillInput(input, name, blob); | |
| else if (typeof window.File === "function") { | |
| try { backfillInput(input, name, new window.File([""], name, { type: f0.mimeType || "" })); } catch (e4) { | |
| try { input.files = [{ name: name, path: f0.path }]; } catch (e5) {} | |
| } | |
| } else { try { input.files = [{ name: name, path: f0.path }]; } catch (e6) {} } | |
| try { input.setAttribute("data-device-picked", "1"); } catch (e7) {} | |
| dispatch(input, "change", { files: files }); | |
| try { dispatch(input, "input", { files: files }); } catch (e8) {} | |
| } | |
| }).catch(function () {}); | |
| } | |
| }); | |
| } | |
| function enhanceAnchor(a) { | |
| if (!a || a.__deviceWired || optedOut(a)) return; | |
| var href = null, dl = null; | |
| try { href = a.getAttribute("href"); dl = a.getAttribute("download"); } catch (e) {} | |
| if (!href || dl === null) return; | |
| a.__deviceWired = true; | |
| a.addEventListener("click", function (e) { | |
| if (!isNative() || optedOut(a)) return; | |
| var FSW = resolvePlugin("Filesystem"), FHW = resolvePlugin("FileSharer"); | |
| if (!(FSW && typeof FSW.ref.writeFile === "function") && !(FHW && typeof FHW.ref.save === "function")) return; | |
| e.preventDefault(); | |
| var name = dl || href.split("?")[0].split("/").pop() || "download.bin"; | |
| fetch(href).then(function (r) { return r.blob(); }).then(function (blob) { | |
| saveFileNative(blob, name).then(function () {}).catch(function () { | |
| try { a.click(); } catch (err) {} | |
| }); | |
| }).catch(function () { | |
| try { | |
| var raw = a.__deviceRawClick; | |
| if (!raw) { a.__deviceWired = false; try { a.click(); } catch (err2) {} a.__deviceWired = true; } | |
| } catch (err3) {} | |
| }); | |
| }); | |
| } | |
| function enhanceFolderButton(el) { | |
| if (!el || el.__deviceWired || optedOut(el)) return; | |
| el.__deviceWired = true; | |
| el.addEventListener("click", function (e) { | |
| if (!isNative() || optedOut(el)) return; | |
| e.preventDefault(); | |
| pickFolderNative().then(function (r) { | |
| if (r) dispatch(el, "devicefolder", r); | |
| }).catch(function () {}); | |
| }); | |
| } | |
| function enhanceAll(root) { | |
| var doc = root || document; | |
| var q = null; | |
| try { q = function (sel) { return doc.querySelectorAll ? doc.querySelectorAll(sel) : []; }; } catch (e) { return; } | |
| var inputs = [], anchors = [], btns = []; | |
| try { inputs = q('input[type="file"]'); } catch (e) { try { inputs = q("input"); } catch (e2) {} } | |
| try { anchors = q("a[download]"); } catch (e3) {} | |
| try { btns = q("[data-device-folder]"); } catch (e4) {} | |
| var i; | |
| for (i = 0; i < (inputs ? inputs.length : 0); i++) { try { enhanceInput(inputs[i]); } catch (e5) {} } | |
| for (i = 0; i < (anchors ? anchors.length : 0); i++) { try { enhanceAnchor(anchors[i]); } catch (e6) {} } | |
| for (i = 0; i < (btns ? btns.length : 0); i++) { try { enhanceFolderButton(btns[i]); } catch (e7) {} } | |
| } | |
| var api = { | |
| __wired: true, | |
| lastPick: null, | |
| enhanceAll: enhanceAll, | |
| isNative: isNative, | |
| resolve: function (name) { var h = resolvePlugin(name); return h ? h.via : null; }, | |
| status: function () { try { return statusSnapshot(); } catch (e) { return { native: false, error: String(e) }; } }, | |
| pickFolder: async function () { | |
| if (!isNative()) return { mode: "web", ok: false, reason: "not-native" }; | |
| var r = await pickFolderNative(); | |
| if (r) return Object.assign({ ok: true }, r); | |
| return { mode: "web", ok: false, reason: "no-provider" }; | |
| }, | |
| pickFiles: async function (opts) { | |
| if (!isNative()) return { mode: "web", ok: false, reason: "not-native" }; | |
| var r = await pickFilesNative(opts); | |
| if (r) return Object.assign({ ok: true }, r); | |
| return { mode: "web", ok: false, reason: "no-provider" }; | |
| }, | |
| saveFile: async function (blob, name, opts) { | |
| if (!isNative()) { | |
| try { | |
| var url = (blob && typeof window.URL !== "undefined" && window.URL.createObjectURL) ? window.URL.createObjectURL(blob) : null; | |
| if (url) { | |
| var a = document.createElement("a"); | |
| a.href = url; a.download = name || "download.bin"; | |
| try { document.body.appendChild(a); } catch (e) {} | |
| try { a.click(); } catch (e2) {} | |
| try { setTimeout(function () { try { document.body.removeChild(a); } catch (e3) {} try { window.URL.revokeObjectURL(url); } catch (e4) {} }, 4000); } catch (e5) {} | |
| } | |
| } catch (e6) {} | |
| return { ok: true, mode: "web" }; | |
| } | |
| return saveFileNative(blob, name, opts); | |
| } | |
| }; | |
| window.DeviceFiles = api; | |
| try { | |
| enhanceAll(document); | |
| if (document.readyState !== "complete" && document.addEventListener) { | |
| document.addEventListener("DOMContentLoaded", function () { try { enhanceAll(document); } catch (e) {} }); | |
| } | |
| if (typeof MutationObserver !== "undefined" && document.documentElement) { | |
| var mo = new MutationObserver(function (muts) { | |
| try { | |
| for (var i = 0; i < muts.length; i++) { | |
| var m = muts[i]; | |
| if (m.addedNodes) { | |
| for (var j = 0; j < m.addedNodes.length; j++) { | |
| var n = m.addedNodes[j]; | |
| if (!n || n.nodeType !== 1) continue; | |
| try { | |
| if (n.tagName === "INPUT" && n.type === "file") enhanceInput(n); | |
| else if (n.tagName === "A" && n.hasAttribute && n.hasAttribute("download")) enhanceAnchor(n); | |
| else if (n.hasAttribute && n.hasAttribute("data-device-folder")) enhanceFolderButton(n); | |
| else if (n.querySelectorAll) { | |
| var ins = n.querySelectorAll('input[type="file"]'); | |
| for (var k = 0; k < ins.length; k++) enhanceInput(ins[k]); | |
| var ans = n.querySelectorAll("a[download]"); | |
| for (var l = 0; l < ans.length; l++) enhanceAnchor(ans[l]); | |
| var bs = n.querySelectorAll("[data-device-folder]"); | |
| for (var q2 = 0; q2 < bs.length; q2++) enhanceFolderButton(bs[q2]); | |
| } | |
| } catch (e2) {} | |
| } | |
| } | |
| } | |
| } catch (e3) {} | |
| }); | |
| try { mo.observe(document.documentElement, { childList: true, subtree: true }); } catch (e4) {} | |
| } else if (document.addEventListener) { | |
| document.addEventListener("DOMNodeInserted", function (e) { try { enhanceAll(document); } catch (err) {} }); | |
| } | |
| } catch (e) {} | |
| })(); | |
| """ | |
| def plugin_pins_for(cap_major: int) -> dict[str, str]: | |
| try: | |
| m = int(cap_major) | |
| except Exception: | |
| m = 0 | |
| if m in DEVICE_PLUGIN_PINS: | |
| return dict(DEVICE_PLUGIN_PINS[m]) | |
| if m >= 8: | |
| return dict(DEVICE_PLUGIN_PINS[8]) | |
| if m == 7: | |
| return dict(DEVICE_PLUGIN_PINS[7]) | |
| return dict(DEVICE_PLUGIN_PINS[6]) | |
| # npm package -> (settings.gradle short id, JS bridge name for registerPlugin). | |
| # Bridge names equal the documented JS import names; registerPlugin() failures | |
| # are caught at runtime (resolvePlugin falls through), so a wrong name degrades | |
| # to "missing" instead of crashing. | |
| PLUGIN_DEFS: dict[str, tuple[str, str]] = { | |
| "@capawesome/capacitor-file-picker": ("capacitor-file-picker", "FilePicker"), | |
| "@capacitor/filesystem": ("capacitor-filesystem", "Filesystem"), | |
| "@capacitor/share": ("capacitor-share", "Share"), | |
| "@capacitor/file-transfer": ("capacitor-file-transfer", "FileTransfer"), | |
| "@capgo/capacitor-file-sharer": ("capacitor-file-sharer", "FileSharer"), | |
| "@daniele-rolli/capacitor-scoped-storage": ("capacitor-scoped-storage", "ScopedStorage"), | |
| "@capacitor-community/media": ("capacitor-media", "Media"), | |
| } | |
| # Script-tag-safe web bundle candidates inside each plugin package. | |
| # dist/plugin.js is the IIFE loader meant for <script> use; ESM builds are | |
| # deliberately NOT used (classic script tag would break on import syntax). | |
| PLUGIN_BUNDLE_CANDIDATES = ("dist/plugin.js", "dist/plugin.cjs.js", "build/plugin.js") | |
| def copy_plugin_bundles(node_root: Path, web_dir: Path, log_fn) -> list[str]: | |
| """Copy installed plugins' IIFE web bundles into built webDir. | |
| Source: <node_root>/node_modules/<pkg>/dist/plugin.js (or fallbacks). | |
| Dest: <web_dir>/cap-plugins/<short>.js. Built copy only, never source. | |
| Missing bundles are skipped with a log line (registerPlugin shim covers | |
| native calls without them). Returns injected relative paths. | |
| """ | |
| copied: list[str] = [] | |
| try: | |
| node_root = Path(node_root) | |
| web_dir = Path(web_dir) | |
| if not web_dir.is_dir(): | |
| return copied | |
| dest_dir = web_dir / "cap-plugins" | |
| for pkg in list(CORE_DEVICE_PLUGINS) + ["@capacitor-community/media"]: | |
| try: | |
| short = PLUGIN_DEFS.get(pkg, (pkg.split("/")[-1], ""))[0] | |
| if (web_dir / "cap-plugins" / f"{short}.js").is_file(): | |
| copied.append(f"cap-plugins/{short}.js") | |
| continue | |
| src = None | |
| for cand in PLUGIN_BUNDLE_CANDIDATES: | |
| p = node_root / "node_modules" / pkg / cand | |
| try: | |
| if p.is_file() and p.stat().st_size > 100: | |
| src = p | |
| break | |
| except Exception: | |
| continue | |
| if src is None: | |
| continue | |
| dest_dir.mkdir(parents=True, exist_ok=True) | |
| (dest_dir / f"{short}.js").write_bytes(src.read_bytes()) | |
| copied.append(f"cap-plugins/{short}.js") | |
| except Exception as e: | |
| try: | |
| log_fn(f"plugin bundle note {pkg}: {e}") | |
| except Exception: | |
| pass | |
| # de-dupe, stable order | |
| seen: set[str] = set() | |
| uniq = [c for c in copied if not (c in seen or seen.add(c))] | |
| if uniq: | |
| try: | |
| log_fn(f"Copied plugin web bundles ({len(uniq)}): {', '.join(uniq)}") | |
| except Exception: | |
| pass | |
| return uniq | |
| except Exception as e: | |
| try: | |
| log_fn(f"plugin bundles skipped: {e}") | |
| except Exception: | |
| pass | |
| return [] | |
| def verify_native_plugins(android_dir: Path, log_fn) -> tuple[list[str], list[str]]: | |
| """Parse android/capacitor.settings.gradle for registered plugin ids. | |
| Logs FOUND/MISSING per device plugin. A MISSING entry means cap sync did | |
| not link the native side — loud build-time signal instead of a mystery | |
| on-device alert later. Returns (found, missing) short ids. | |
| """ | |
| found: list[str] = [] | |
| missing: list[str] = [] | |
| try: | |
| android_dir = Path(android_dir) | |
| txt = "" | |
| for name in ("capacitor.settings.gradle", "settings.gradle"): | |
| try: | |
| p = android_dir / name | |
| if p.is_file(): | |
| txt += "\n" + p.read_text(encoding="utf-8", errors="ignore") | |
| except Exception: | |
| continue | |
| want = [PLUGIN_DEFS[p][0] for p in CORE_DEVICE_PLUGINS] | |
| for short in want: | |
| (found if short in txt else missing).append(short) | |
| try: | |
| if found: | |
| log_fn(f"Native plugins linked ({len(found)}): {', '.join(found)}") | |
| if missing: | |
| log_fn(f"WARNING: native plugins NOT linked ({len(missing)}): {', '.join(missing)} — cap sync may have missed them; native pick/save can degrade") | |
| except Exception: | |
| pass | |
| except Exception as e: | |
| try: | |
| log_fn(f"native plugin verify note: {e}") | |
| except Exception: | |
| pass | |
| return found, missing | |
| def write_device_helper(web_dir: Path) -> Path | None: | |
| """Write device-files.js into built webDir. Returns path or None.""" | |
| try: | |
| web_dir = Path(web_dir) | |
| if not web_dir.is_dir(): | |
| return None | |
| target = web_dir / "device-files.js" | |
| if target.exists(): | |
| try: | |
| if "window.DeviceFiles" in target.read_text(encoding="utf-8", errors="ignore"): | |
| return target | |
| except Exception: | |
| pass | |
| target.write_text(DEVICE_FILES_JS, encoding="utf-8") | |
| return target | |
| except Exception: | |
| return None | |
| def inject_device_helper(web_dir: Path, log_fn, extra_scripts: list[str] | None = None) -> bool: | |
| """Inject plugin bundles + <script src=device-files.js> into webDir html. | |
| extra_scripts (e.g. cap-plugins/*.js) are injected BEFORE device-files.js | |
| so registerPlugin-based proxies exist when the helper boots. Operates on | |
| the built copy only (never source). Idempotent via marker. Ensures <head> | |
| exists (Capacitor hard-requires it for plugin injection). | |
| """ | |
| try: | |
| web_dir = Path(web_dir) | |
| if not web_dir.is_dir(): | |
| return False | |
| helper = write_device_helper(web_dir) | |
| if helper is None: | |
| return False | |
| tags: list[str] = [] | |
| for rel in extra_scripts or []: | |
| try: | |
| if (web_dir / rel).is_file() and rel not in tags: | |
| tags.append(rel) | |
| except Exception: | |
| continue | |
| tags.append("device-files.js") | |
| patched_any = False | |
| html_files = [web_dir / "index.html"] + [ | |
| p for p in web_dir.rglob("*.html") if p.name != "index.html" | |
| ] | |
| seen: set[str] = set() | |
| for html in html_files: | |
| try: | |
| key = str(html.resolve()) | |
| except Exception: | |
| key = str(html) | |
| if key in seen: | |
| continue | |
| seen.add(key) | |
| try: | |
| if not html.is_file(): | |
| continue | |
| txt = html.read_text(encoding="utf-8", errors="ignore") | |
| if "device-files.js" in txt: | |
| continue | |
| head_tags = "".join(f'\n <script src="{t}" defer></script>' for t in tags) | |
| head_block = "".join(f'\n <script src="{t}" defer></script>' for t in tags) | |
| if "</head>" in txt: | |
| txt = txt.replace("</head>", " " + head_tags + "\n</head>", 1) | |
| elif "<head>" in txt: | |
| txt = txt.replace("<head>", "<head>" + head_block, 1) | |
| elif "<html" in txt: | |
| txt = re.sub( | |
| r"(<html[^>]*>)", | |
| r"\1<head>" + head_block + "\n</head>", | |
| txt, | |
| count=1, | |
| ) | |
| else: | |
| txt = "<head>" + head_block + "\n</head>\n" + txt | |
| html.write_text(txt, encoding="utf-8") | |
| patched_any = True | |
| except Exception as e: | |
| try: | |
| log_fn(f"device helper inject note {html.name}: {e}") | |
| except Exception: | |
| pass | |
| try: | |
| log_fn( | |
| "Device helper wired into web output: file inputs use native pickers" | |
| if patched_any | |
| else "Device helper present (already wired)" | |
| ) | |
| except Exception: | |
| pass | |
| return True | |
| except Exception as e: | |
| try: | |
| log_fn(f"device helper wiring skipped: {e}") | |
| except Exception: | |
| pass | |
| return False | |
| def ensure_file_paths_xml(android_dir: Path, log_fn) -> None: | |
| """Ensure FileProvider paths for Share (caches + external + documents).""" | |
| try: | |
| xml_dir = Path(android_dir) / "app" / "src" / "main" / "res" / "xml" | |
| xml_dir.mkdir(parents=True, exist_ok=True) | |
| fp = xml_dir / "file_paths.xml" | |
| want = ( | |
| '<?xml version="1.0" encoding="utf-8"?>\n' | |
| "<paths>\n" | |
| ' <cache-path name="cache" path="." />\n' | |
| ' <external-cache-path name="external_cache" path="." />\n' | |
| ' <external-files-path name="external_files" path="." />\n' | |
| ' <external-path name="external" path="." />\n' | |
| ' <files-path name="files" path="." />\n' | |
| "</paths>\n" | |
| ) | |
| try: | |
| cur = fp.read_text(encoding="utf-8", errors="ignore") if fp.exists() else "" | |
| except Exception: | |
| cur = "" | |
| if "cache-path" not in cur or "external-files-path" not in cur: | |
| fp.write_text(want, encoding="utf-8") | |
| try: | |
| log_fn("Ensured file_paths.xml for Share/FileProvider") | |
| except Exception: | |
| pass | |
| except Exception as e: | |
| try: | |
| log_fn(f"file_paths.xml note: {e}") | |
| except Exception: | |
| pass | |
| def ensure_device_permissions(android_dir: Path, log_fn, gallery: bool = False) -> None: | |
| """Minimal Play-safe permissions (picker-granted URIs need no broad perms). | |
| Default (gallery=False, per media v7+ non-gallery guidance): only | |
| READ_EXTERNAL_STORAGE maxSdk=32 (EXIF/external read) and | |
| WRITE_EXTERNAL_STORAGE maxSdk=29 (pre-scoped). Broad READ_MEDIA_* are | |
| added ONLY for gallery-mode apps (Play rejects them otherwise). | |
| Idempotent, never duplicates. | |
| """ | |
| try: | |
| man = Path(android_dir) / "app" / "src" / "main" / "AndroidManifest.xml" | |
| if not man.is_file(): | |
| return | |
| txt = man.read_text(encoding="utf-8", errors="ignore") | |
| orig = txt | |
| def _add(perm: str, extra: str = ""): | |
| nonlocal txt | |
| if perm in txt: | |
| return | |
| tag = f' <uses-permission android:name="{perm}"{extra} />\n' | |
| if "<application" in txt: | |
| txt = txt.replace("<application", tag + " <application", 1) | |
| else: | |
| txt = txt.rstrip() + "\n" + tag | |
| _add("android.permission.READ_EXTERNAL_STORAGE", ' android:maxSdkVersion="32"') | |
| _add("android.permission.WRITE_EXTERNAL_STORAGE", ' android:maxSdkVersion="29"') | |
| if gallery: | |
| _add("android.permission.READ_MEDIA_IMAGES") | |
| _add("android.permission.READ_MEDIA_VIDEO") | |
| if txt != orig: | |
| man.write_text(txt, encoding="utf-8") | |
| try: | |
| log_fn("Ensured minimal device permissions (scoped, Play-safe)") | |
| except Exception: | |
| pass | |
| except Exception as e: | |
| try: | |
| log_fn(f"device permissions note: {e}") | |
| except Exception: | |
| pass | |
| def ensure_proguard_device_rules(android_dir: Path, log_fn) -> None: | |
| """Append -keep rules for device plugins (never overwrite user rules).""" | |
| try: | |
| pro = Path(android_dir) / "app" / "proguard-rules.pro" | |
| marker = "# --- device plugins (builder) ---" | |
| rules = ( | |
| marker + "\n" | |
| "-keep class com.capacitorjs.plugins.** { *; }\n" | |
| "-keep class com.capawesome.** { *; }\n" | |
| "-keep class com.capgo.** { *; }\n" | |
| "-keep class com.daniellerolli.** { *; }\n" | |
| "-keep class com.capacitorcommunity.** { *; }\n" | |
| ) | |
| try: | |
| cur = pro.read_text(encoding="utf-8", errors="ignore") if pro.exists() else "" | |
| except Exception: | |
| cur = "" | |
| if marker not in cur: | |
| pro.parent.mkdir(parents=True, exist_ok=True) | |
| pro.write_text((cur.rstrip() + "\n" if cur.strip() else "") + rules, encoding="utf-8") | |
| try: | |
| log_fn("Appended ProGuard keeps for device plugins") | |
| except Exception: | |
| pass | |
| except Exception as e: | |
| try: | |
| log_fn(f"device proguard note: {e}") | |
| except Exception: | |
| pass | |