qw / capacitor_plugins.py
ShadowHunter222's picture
Upload 3 files
ad1625e verified
Raw History Blame Contribute Delete
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