diff --git a/AGENTS.md b/AGENTS.md index ac2f150..74b7522 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,6 +126,14 @@ and recurring pitfalls only. - iOS/iPadOS file pickers can gray out `.apk/.apks/.apkm/.xapk` when `accept` is set. Use `file-picker-support.js` to relax picker filtering on Apple mobile WebKit while keeping in-app validation. +- Desktop Chromium PWA file associations are declared in + `packages/apk-webui/src/public/manifest.webmanifest`. Keep `launchQueue` + handling progressive and route received `File` objects through the existing + local analyzer flow; Safari and Firefox must retain picker/drag-and-drop + fallback behavior. +- The PWA currently has no offline guarantee and intentionally has no service + worker. Add one only with an explicit cache/update design for the analyzer, + locale, report, and generated-rule chunks. - Liquid/glass effects must be capability- and power-mode gated, not OS-name gated. Keep fallback reasons in `liquid-glass-support.js`. - Web UI CSS/JS budgets are intentionally tight. Before raising a budget, prefer diff --git a/locales/en.json b/locales/en.json index 678cc66..9682929 100644 --- a/locales/en.json +++ b/locales/en.json @@ -82,6 +82,19 @@ "reportShareCopy": "Copy link", "reportShareSystem": "System share", "reportShareNoLink": "Link unavailable", + "pwaGuideEyebrow": "Desktop shortcut", + "pwaGuideTitle": "Analyze from the right-click menu", + "pwaGuideBody": "Install LibChecker WebUI, then open APK, APKS, APKM, or XAPK files with it directly from Finder or File Explorer.", + "pwaGuideInstall": "Install app", + "pwaGuideDismiss": "Dismiss installation guide", + "pwaGuideManual": "No install prompt? Use the install icon in the Chrome or Edge address bar, or choose Install LibChecker WebUI from the browser menu.", + "pwaGuideFirstOpenNote": "The browser asks you to confirm file access the first time.", + "pwaGuideStepInstall": "Install LibChecker WebUI", + "pwaGuideStepOpen": "Choose LibChecker WebUI from Open with", + "pwaGuideStepAnalyze": "Analyze the package locally", + "pwaGuideMenuOpenWith": "Open with LibChecker WebUI", + "pwaGuideSceneDone": "Report ready", + "pwaFileOpenFailed": "The Android package could not be opened from the system menu.", "clear": "Clear", "filePrompt": "Choose or drop", "fileIdle": "No file selected", diff --git a/locales/ja.json b/locales/ja.json index 1b18751..2f9c092 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -82,6 +82,19 @@ "reportShareCopy": "リンクをコピー", "reportShareSystem": "システム共有", "reportShareNoLink": "リンクを利用できません", + "pwaGuideEyebrow": "デスクトップショートカット", + "pwaGuideTitle": "右クリックメニューから直接解析", + "pwaGuideBody": "LibChecker WebUI をインストールすると、Finder またはエクスプローラーから APK、APKS、APKM、XAPK ファイルを直接開けます。", + "pwaGuideInstall": "アプリをインストール", + "pwaGuideDismiss": "インストールガイドを閉じる", + "pwaGuideManual": "インストール案内が表示されない場合は、Chrome または Edge のアドレスバーにあるインストールアイコンか、ブラウザーメニューの「LibChecker WebUI をインストール」を使用してください。", + "pwaGuideFirstOpenNote": "初回のみ、ブラウザーがファイルアクセスの確認を求めます。", + "pwaGuideStepInstall": "LibChecker WebUI をインストール", + "pwaGuideStepOpen": "「このアプリケーションで開く」から LibChecker WebUI を選択", + "pwaGuideStepAnalyze": "パッケージをローカルで解析", + "pwaGuideMenuOpenWith": "LibChecker WebUI で開く", + "pwaGuideSceneDone": "レポートが完成", + "pwaFileOpenFailed": "システムメニューから Android パッケージを開けませんでした。", "clear": "クリア", "filePrompt": "選択またはドロップ", "fileIdle": "ファイルが選択されていません", diff --git a/locales/ko.json b/locales/ko.json index 677ccf9..6a8e955 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -82,6 +82,19 @@ "reportShareCopy": "링크 복사", "reportShareSystem": "시스템 공유", "reportShareNoLink": "링크를 사용할 수 없습니다", + "pwaGuideEyebrow": "데스크톱 바로 가기", + "pwaGuideTitle": "오른쪽 클릭 메뉴에서 바로 분석", + "pwaGuideBody": "LibChecker WebUI를 설치하면 Finder 또는 파일 탐색기에서 APK, APKS, APKM, XAPK 파일을 바로 열 수 있습니다.", + "pwaGuideInstall": "앱 설치", + "pwaGuideDismiss": "설치 안내 닫기", + "pwaGuideManual": "설치 안내가 표시되지 않으면 Chrome 또는 Edge 주소 표시줄의 설치 아이콘이나 브라우저 메뉴의 ‘LibChecker WebUI 설치’를 이용하세요.", + "pwaGuideFirstOpenNote": "처음 사용할 때 브라우저가 파일 접근 확인을 요청합니다.", + "pwaGuideStepInstall": "LibChecker WebUI 설치", + "pwaGuideStepOpen": "‘연결 프로그램’에서 LibChecker WebUI 선택", + "pwaGuideStepAnalyze": "패키지를 로컬에서 분석", + "pwaGuideMenuOpenWith": "LibChecker WebUI로 열기", + "pwaGuideSceneDone": "보고서 준비 완료", + "pwaFileOpenFailed": "시스템 메뉴에서 Android 패키지를 열 수 없습니다.", "clear": "지우기", "filePrompt": "선택하거나 끌어 놓기", "fileIdle": "선택한 파일 없음", diff --git a/locales/zh-Hans.json b/locales/zh-Hans.json index a0c2744..dcde1f0 100644 --- a/locales/zh-Hans.json +++ b/locales/zh-Hans.json @@ -82,6 +82,19 @@ "reportShareCopy": "复制链接", "reportShareSystem": "系统分享", "reportShareNoLink": "链接不可用", + "pwaGuideEyebrow": "桌面快捷操作", + "pwaGuideTitle": "从右键菜单直接分析", + "pwaGuideBody": "安装 LibChecker WebUI 后,即可在 Finder 或文件资源管理器中直接用它打开 APK、APKS、APKM 或 XAPK 文件。", + "pwaGuideInstall": "安装", + "pwaGuideDismiss": "关闭安装指引", + "pwaGuideManual": "没有出现安装提示?请点击 Chrome 或 Edge 地址栏中的安装图标,或从浏览器菜单选择「安装 LibChecker WebUI」。", + "pwaGuideFirstOpenNote": "首次使用时,浏览器会请求确认文件访问权限。", + "pwaGuideStepInstall": "安装 LibChecker WebUI", + "pwaGuideStepOpen": "在「打开方式」中选择 LibChecker WebUI", + "pwaGuideStepAnalyze": "在本地分析安装包", + "pwaGuideMenuOpenWith": "使用 LibChecker WebUI 打开", + "pwaGuideSceneDone": "报告已生成", + "pwaFileOpenFailed": "无法从系统菜单打开这个 Android 安装包。", "clear": "清空", "filePrompt": "选择或拖入", "fileIdle": "未选择文件", diff --git a/locales/zh-Hant.json b/locales/zh-Hant.json index 4ce209a..daba1f1 100644 --- a/locales/zh-Hant.json +++ b/locales/zh-Hant.json @@ -82,6 +82,19 @@ "reportShareCopy": "複製連結", "reportShareSystem": "系統分享", "reportShareNoLink": "連結無法使用", + "pwaGuideEyebrow": "桌面快速操作", + "pwaGuideTitle": "從右鍵選單直接分析", + "pwaGuideBody": "安裝 LibChecker WebUI 後,即可在 Finder 或檔案總管中直接用它開啟 APK、APKS、APKM 或 XAPK 檔案。", + "pwaGuideInstall": "安裝應用程式", + "pwaGuideDismiss": "關閉安裝指引", + "pwaGuideManual": "沒有出現安裝提示?請按一下 Chrome 或 Edge 網址列中的安裝圖示,或從瀏覽器選單選擇「安裝 LibChecker WebUI」。", + "pwaGuideFirstOpenNote": "首次使用時,瀏覽器會要求確認檔案存取權限。", + "pwaGuideStepInstall": "安裝 LibChecker WebUI", + "pwaGuideStepOpen": "在「開啟方式」中選擇 LibChecker WebUI", + "pwaGuideStepAnalyze": "在本機分析安裝包", + "pwaGuideMenuOpenWith": "使用 LibChecker WebUI 開啟", + "pwaGuideSceneDone": "報告已建立", + "pwaFileOpenFailed": "無法從系統選單開啟這個 Android 安裝包。", "clear": "清除", "filePrompt": "選擇或拖放", "fileIdle": "尚未選擇檔案", diff --git a/packages/apk-webui/scripts/build.mjs b/packages/apk-webui/scripts/build.mjs index 3ad6955..aac5aca 100644 --- a/packages/apk-webui/scripts/build.mjs +++ b/packages/apk-webui/scripts/build.mjs @@ -40,6 +40,13 @@ await writeFile( "/index.html", " Cache-Control: no-cache", "", + "/manifest.webmanifest", + " Content-Type: application/manifest+json; charset=UTF-8", + " Cache-Control: no-cache", + "", + "/pwa-icon-*.png", + " Cache-Control: public, max-age=3600", + "", "/social-preview.png", " Cache-Control: public, max-age=3600", "", diff --git a/packages/apk-webui/src/app.js b/packages/apk-webui/src/app.js index a159974..9d5a0bf 100644 --- a/packages/apk-webui/src/app.js +++ b/packages/apk-webui/src/app.js @@ -158,6 +158,8 @@ const tabDrag = createReportTabDragState(); const runtime = createRuntimeState(); const elements = collectAppElements(); +initPwaLifecycle(); + function t(key, variables = {}) { return translate(state.locale, key, variables); } @@ -1198,6 +1200,162 @@ function applyFilePickerAcceptCompatibilityWhenReady() { window.setTimeout(apply, 3200); } +function initPwaLifecycle() { + window.addEventListener("beforeinstallprompt", (event) => { + event.preventDefault(); + runtime.pwaInstallPrompt = event; + runtime.pwaInstallGuideController?.setInstallAvailable(true); + }); + + window.addEventListener("appinstalled", () => { + runtime.pwaInstallPrompt = null; + if (runtime.pwaInstallGuideController) { + runtime.pwaInstallGuideController.markInstalled(); + } else { + void loadPwaInstallGuideModule() + .then(({ rememberPwaInstallation }) => rememberPwaInstallation()) + .catch(() => {}); + } + trackWebEvent("webui.pwa_install.installed", { + result: "accepted", + operation: "appinstalled", + }); + }); +} + +function initPwaFileHandling() { + if (typeof window.launchQueue?.setConsumer !== "function") { + return; + } + + void import("./app/pwa-file-handler.js") + .then(({ registerPwaFileHandler }) => { + const registered = registerPwaFileHandler(handlePwaLaunchedFile, { + launchQueueRef: window.launchQueue, + onError: handlePwaFileLaunchError, + }); + if (registered) { + appendRuntimeLog("debug", "PWA file handler registered"); + } + }) + .catch(handlePwaFileLaunchError); +} + +async function handlePwaLaunchedFile(file) { + if (!(file instanceof File)) { + throw new TypeError("The launched item is not a file"); + } + + if (state.analyzeBusy) { + resetState(); + } + setAppMode("analyze"); + setSelectedFile(file, { inputSource: "file_handler" }); + trackWebEvent("webui.file_handler.opened", { + result: isLikelyApk(file) ? "valid" : "invalid", + input_source: "file_handler", + ...getFileAnalyticsFields(file), + }); + await analyzeSelectedFile(); +} + +function handlePwaFileLaunchError(error) { + showError(t("pwaFileOpenFailed")); + trackWebEvent("webui.file_handler.failed", { + result: "error", + input_source: "file_handler", + ...getClientErrorTelemetryFields(error), + }); +} + +function loadPwaInstallGuideModule() { + if (!runtime.pwaInstallGuideModulePromise) { + runtime.pwaInstallGuideModulePromise = import("./app/pwa-install-guide.js") + .catch((error) => { + runtime.pwaInstallGuideModulePromise = null; + throw error; + }); + } + return runtime.pwaInstallGuideModulePromise; +} + +async function maybeShowPwaInstallGuide() { + const report = state.report; + if (!report || !elements.pwaInstallGuide) { + return; + } + + try { + const { createPwaInstallGuide } = await loadPwaInstallGuideModule(); + if (state.report !== report || state.appMode !== "analyze") { + return; + } + if (!runtime.pwaInstallGuideController) { + runtime.pwaInstallGuideController = createPwaInstallGuide({ + root: elements.pwaInstallGuide, + t, + onInstall: promptPwaInstall, + onEvent: trackPwaInstallGuideEvent, + isPowerConstrained: isAppPowerConstrained, + }); + } + runtime.pwaInstallGuideController.show({ + installAvailable: Boolean(runtime.pwaInstallPrompt), + }); + } catch (error) { + trackWebEvent("webui.pwa_guide.load_failed", { + result: "error", + operation: "load", + ...getClientErrorTelemetryFields(error), + }); + } +} + +function hidePwaInstallGuide() { + runtime.pwaInstallGuideController?.hide(); +} + +function trackPwaInstallGuideEvent(operation) { + trackWebEvent(`webui.pwa_guide.${operation}`, { + result: "success", + operation, + }); +} + +async function promptPwaInstall() { + const installPrompt = runtime.pwaInstallPrompt; + if (!installPrompt) { + runtime.pwaInstallGuideController?.setInstallAvailable(false); + return; + } + + runtime.pwaInstallPrompt = null; + runtime.pwaInstallGuideController?.setInstallAvailable(false); + trackWebEvent("webui.pwa_install.prompted", { + result: "started", + operation: "custom_prompt", + }); + + try { + await installPrompt.prompt(); + const choice = await installPrompt.userChoice; + const outcome = choice?.outcome === "accepted" ? "accepted" : "dismissed"; + trackWebEvent("webui.pwa_install.completed", { + result: outcome, + operation: "custom_prompt", + }); + if (outcome === "accepted") { + runtime.pwaInstallGuideController?.markInstalled(); + } + } catch (error) { + trackWebEvent("webui.pwa_install.failed", { + result: "error", + operation: "custom_prompt", + ...getClientErrorTelemetryFields(error), + }); + } +} + void initializeApp(); async function initializeApp() { @@ -1212,6 +1370,7 @@ async function initializeApp() { updateHistoryCollapse(); updateAppMode(); bindEvents(); + initPwaFileHandling(); initWebMcpWhenAvailable(); syncMobileBottomControls(); scheduleColorOrbBackground(); @@ -2922,6 +3081,7 @@ function applyLocale() { renderRuntimeLogs(); renderLcappsPicker(); renderLcappsBubble(); + runtime.pwaInstallGuideController?.refresh(); if (runtime.topbarIdentityMode !== "report" || !state.report) { renderTopbarDefaultIdentity({ force: true }); } @@ -3145,12 +3305,15 @@ function renderLanguageOptions() { .join(""); } -function setSelectedFile(file) { +function setSelectedFile(file, options = {}) { hideError(); if (file) { setDownloadUrl("", { keepFile: true, syncInput: true }); } state.selectedFile = file; + state.selectedFileInputSource = file && options.inputSource === "file_handler" + ? "file_handler" + : "upload"; renderSelectedFile(); updateAnalyzeControls(); updateClearButton(); @@ -3161,6 +3324,7 @@ function setSelectedFile(file) { trackWebEvent("webui.file.selected", { result: isValid ? "valid" : "invalid", is_valid: isValid, + input_source: state.selectedFileInputSource, file_kind: isLcapps ? "lcapps" : "android_package", ...getFileAnalyticsFields(file), }); @@ -3182,6 +3346,7 @@ function setDownloadUrl(value, options = {}) { if (state.downloadUrl && !options.keepFile) { state.selectedFile = null; + state.selectedFileInputSource = "upload"; elements.fileInput.value = ""; renderSelectedFile(); } @@ -3316,6 +3481,7 @@ function closeReportShareModal(options = {}) { async function analyzeSelectedFile() { const file = state.selectedFile; + const inputSource = state.selectedFileInputSource || "upload"; hideError(); if (!file) { @@ -3323,6 +3489,7 @@ async function analyzeSelectedFile() { trackWebEvent("webui.analysis.failed", { result: "missing_file", error_name: "MissingFile", + input_source: inputSource, }); return; } @@ -3337,6 +3504,7 @@ async function analyzeSelectedFile() { trackWebEvent("webui.analysis.failed", { result: "invalid_file", error_name: "InvalidFile", + input_source: inputSource, ...getFileAnalyticsFields(file), }); return; @@ -3348,6 +3516,7 @@ async function analyzeSelectedFile() { trackWebEvent("webui.analysis.failed", { result: "worker_unavailable", error_name: "WorkerUnavailable", + input_source: inputSource, ...getFileAnalyticsFields(file), }); return; @@ -3356,6 +3525,7 @@ async function analyzeSelectedFile() { state.jobId += 1; const jobId = state.jobId; state.startedAt = performance.now(); + hidePwaInstallGuide(); clearCurrentReportSource(); state.report = null; clearReportShareState(); @@ -3365,6 +3535,7 @@ async function analyzeSelectedFile() { state.jobs.set(jobId, { type: "analyze", sourceFile: file, + inputSource, }); void loadSdkIconRendererModule().catch(() => {}); @@ -3375,7 +3546,7 @@ async function analyzeSelectedFile() { startTimer(); trackWebEvent("webui.analysis.started", { result: "started", - input_source: "upload", + input_source: inputSource, ...getFileAnalyticsFields(file), }); @@ -3401,7 +3572,7 @@ async function analyzeSelectedFile() { trackWebEvent("webui.analysis.failed", { result: "error", error_name: "ReadFileFailed", - input_source: "upload", + input_source: inputSource, ...getFileAnalyticsFields(file), ...getClientErrorTelemetryFields(error), }); @@ -3451,6 +3622,7 @@ async function analyzeDownloadUrl() { const jobId = state.jobId; const abortController = new AbortController(); state.startedAt = performance.now(); + hidePwaInstallGuide(); clearCurrentReportSource(); state.report = null; clearReportShareState(); @@ -3672,6 +3844,7 @@ function failActiveWorkerJobs(message) { trackWebEvent("webui.analysis.failed", { result: "worker_error", error_name: "AnalyzerWorkerError", + input_source: job.inputSource || "upload", }); } } @@ -3723,6 +3896,7 @@ function handleWorkerMessage(event) { trackWebEvent("webui.analysis.failed", { result: "error", error_name: "AnalyzerWorkerError", + input_source: job.inputSource || "upload", }); } return; @@ -3755,13 +3929,14 @@ function handleWorkerMessage(event) { void renderReport() .then(() => { revealReportHeroAfterAnalysis(message.report); + void maybeShowPwaInstallGuide(); }) .catch(() => {}); scheduleHistoryReportSave(message.report); scheduleReportSdkRuleDetailHydration(message.report); trackWebEvent("webui.analysis.succeeded", { result: "success", - input_source: "upload", + input_source: job.inputSource || "upload", ...getReportAnalyticsFields(message.report), }); scheduleWorkerIdleTermination(); @@ -4073,6 +4248,7 @@ async function exportRuntimeLogs() { function resetState() { const hadContent = Boolean(state.selectedFile || state.downloadUrl || state.report || state.activeAnalyzeJobId != null); + hidePwaInstallGuide(); cancelLcappsReportActivation(); clearLcappsBubbleTransitionTimer(); state.linkAbortController?.abort(); @@ -4086,6 +4262,7 @@ function resetState() { hideError(); clearCurrentReportSource(); state.selectedFile = null; + state.selectedFileInputSource = "upload"; state.downloadUrl = ""; state.linkStatusKey = "linkIdle"; state.report = null; @@ -4750,6 +4927,7 @@ async function renderReport() { } if (!state.report) { + hidePwaInstallGuide(); showEmptyReportState(elements); setTopbarReportIdentity(false, { animate: true }); updateReportShareControls(); diff --git a/packages/apk-webui/src/app/pwa-file-handler.js b/packages/apk-webui/src/app/pwa-file-handler.js new file mode 100644 index 0000000..eb1f939 --- /dev/null +++ b/packages/apk-webui/src/app/pwa-file-handler.js @@ -0,0 +1,45 @@ +/** + * Register the installed PWA as a single-package launch consumer. + * + * The manifest uses `multiple-clients`, so a conforming browser sends one file + * to each app window. The first valid handle is still selected defensively for + * browsers that provide more than one handle in a launch. + * + * @param {(file: File) => void | Promise} onFile + * @param {{ launchQueueRef?: LaunchQueue | null, onError?: (error: unknown) => void }} [options] + * @returns {boolean} Whether a launch consumer was registered. + */ +export function registerPwaFileHandler(onFile, options = {}) { + if (typeof onFile !== "function") { + throw new TypeError("PWA file handling requires an onFile callback"); + } + + const launchQueueRef = options.launchQueueRef ?? globalThis.launchQueue; + if (typeof launchQueueRef?.setConsumer !== "function") { + return false; + } + + const onError = typeof options.onError === "function" + ? options.onError + : reportPwaFileHandlerError; + + launchQueueRef.setConsumer(async (launchParams) => { + const fileHandle = Array.from(launchParams?.files || []) + .find((handle) => typeof handle?.getFile === "function"); + if (!fileHandle) { + return; + } + + try { + await onFile(await fileHandle.getFile()); + } catch (error) { + onError(error); + } + }); + + return true; +} + +function reportPwaFileHandlerError(error) { + console.error("Failed to open the launched Android package", error); +} diff --git a/packages/apk-webui/src/app/pwa-install-guide.js b/packages/apk-webui/src/app/pwa-install-guide.js new file mode 100644 index 0000000..6e17bb8 --- /dev/null +++ b/packages/apk-webui/src/app/pwa-install-guide.js @@ -0,0 +1,541 @@ +import "../pwa-install-guide.css"; + +import { escapeAttr, escapeHtml } from "./html.js"; + +const DISMISS_STORAGE_KEY = "apk-webui-pwa-guide-dismissed-until"; +const INSTALLED_STORAGE_KEY = "apk-webui-pwa-installed-until"; +const DISMISS_DURATION_MS = 30 * 24 * 60 * 60 * 1000; +const INSTALLED_MARKER_DURATION_MS = 180 * 24 * 60 * 60 * 1000; +const AUTO_REPLAY_DELAY_MS = 2400; +const GUIDE_INSTALL_BUTTON_RIGHT = 466; +const GUIDE_INSTALL_BUTTON_PADDING = 11; +const GUIDE_INSTALL_BUTTON_ICON_WIDTH = 18; +const GUIDE_INSTALL_BUTTON_GAP = 8; +const INSTALL_DESKTOP_ICON_PATH = "M480-160q-17 0-28.5-11.5T440-200v-40H160q-33 0-56.5-23.5T80-320v-480q0-33 23.5-56.5T160-880h280v80H160v480h640v-120h80v120q0 33-23.5 56.5T800-240H520v40q0 17-11.5 28.5T480-160ZM760-480 560-680l56-56 104 104v-248h80v248l104-104 56 56-200 200Z"; +const LIBCHECKER_ICON_PATH = "M139.391 222.718H129.667C125.942 222.566 123 219.502 123 215.773C123 212.045 125.942 208.98 129.667 208.828H139.391V194.942H129.667C125.942 194.79 123 191.726 123 187.997C123 184.268 125.942 181.204 129.667 181.052H139.391V167.166H129.667C125.942 167.014 123 163.95 123 160.221C123 156.492 125.942 153.428 129.667 153.276L139.391 153.277V150.499C139.39 147.552 140.559 144.726 142.643 142.643C144.726 140.56 147.552 139.39 150.498 139.391H153.277V129.668C153.429 125.942 156.493 123 160.222 123C163.951 123 167.015 125.942 167.167 129.668V139.39L181.053 139.391V129.668C181.205 125.942 184.269 123 187.998 123C191.726 123 194.791 125.942 194.943 129.668V139.391H208.829V129.668C208.981 125.942 212.045 123 215.774 123C219.502 123 222.567 125.942 222.719 129.668L222.717 139.39H225.496C228.443 139.388 231.269 140.558 233.354 142.641C235.438 144.724 236.609 147.55 236.609 150.497V153.276H246.333C250.058 153.428 253 156.492 253 160.221C253 163.95 250.058 167.014 246.333 167.166H236.609V181.052H246.333C250.058 181.204 253 184.268 253 187.997C253 191.726 250.058 194.79 246.333 194.942H236.609V208.827H246.333C250.058 208.979 253 212.043 253 215.772C253 219.501 250.058 222.565 246.333 222.717H236.609V225.496C236.609 231.633 231.633 236.609 225.496 236.609H222.717V246.332C222.565 250.058 219.501 253 215.772 253C212.044 253 208.979 250.058 208.827 246.332V236.609H194.941V246.332C194.789 250.058 191.725 253 187.996 253C184.268 253 181.203 250.058 181.051 246.332V236.609H167.166V246.332C167.014 250.058 163.949 253 160.22 253C156.492 253 153.427 250.058 153.275 246.332L153.277 236.61H150.498C144.361 236.61 139.391 231.635 139.391 225.497V222.718ZM202.723 167.162H173.277C169.886 167.162 167.167 169.887 167.167 173.272V202.718C167.163 204.34 167.805 205.896 168.952 207.043C170.099 208.19 171.655 208.832 173.277 208.828H202.723C206.113 208.828 208.833 206.103 208.833 202.718V173.272C208.833 169.881 206.108 167.162 202.723 167.162Z"; + +export function createPwaInstallGuide({ + root, + t, + onInstall, + onEvent, + isPowerConstrained, + windowRef = window, + navigatorRef = navigator, + storage = window.localStorage, + now = () => Date.now(), +}) { + let installAvailable = false; + let intersectionObserver = null; + let autoReplayTimer = null; + let presentedThisSession = false; + const reducedMotionMedia = getReducedMotionMedia(windowRef); + + function show(options = {}) { + installAvailable = Boolean(options.installAvailable); + if (!isEligible()) { + hide(); + return false; + } + + if (presentedThisSession) { + syncInstallAvailability(); + return !root.hidden; + } + + render(); + root.hidden = false; + syncSvgGeometryAfterRender(); + presentedThisSession = true; + schedulePlayback(); + onEvent?.("viewed"); + return true; + } + + function refresh() { + if (root.hidden) { + return; + } + render(); + syncSvgGeometryAfterRender(); + schedulePlayback(); + } + + function setInstallAvailable(value) { + installAvailable = Boolean(value); + syncInstallAvailability(); + } + + function markInstalled() { + rememberPwaInstallation({ storage, timestamp: now() }); + hide(); + } + + function dismiss() { + writeExpiry(storage, DISMISS_STORAGE_KEY, now() + DISMISS_DURATION_MS); + onEvent?.("dismissed"); + hide(); + } + + function hide() { + clearAutoReplay(); + intersectionObserver?.disconnect(); + intersectionObserver = null; + root.classList.remove("is-playing", "is-complete"); + root.hidden = true; + } + + function isEligible() { + return ( + Boolean(getDesktopPlatform(navigatorRef)) && + supportsFileHandling(windowRef) && + !isRunningInstalled(windowRef, navigatorRef) && + !hasActiveExpiry(storage, DISMISS_STORAGE_KEY, now()) && + !hasActiveExpiry(storage, INSTALLED_STORAGE_KEY, now()) + ); + } + + function render() { + root.className = "pwa-install-guide"; + root.setAttribute("aria-labelledby", "pwa-install-guide-title"); + root.innerHTML = buildGuideHtml(t, { + installAvailable, + }); + root.querySelector("[data-pwa-guide-dismiss]")?.addEventListener("click", dismiss); + root.querySelector("[data-pwa-guide-install]")?.addEventListener("click", async (event) => { + const button = event.currentTarget; + button.disabled = true; + root.classList.add("is-installing"); + try { + await onInstall?.(); + } finally { + root.classList.remove("is-installing"); + if (button.isConnected) { + button.disabled = false; + } + } + }); + } + + function syncInstallAvailability() { + const button = root.querySelector("[data-pwa-guide-install]"); + const actions = root.querySelector(".pwa-install-guide__actions"); + if (button) { + button.hidden = !installAvailable; + } + if (actions) { + actions.hidden = !installAvailable; + } + root.classList.toggle("has-install-prompt", installAvailable); + } + + function syncSvgGeometry() { + fitInstallSceneButton(); + syncCheckmarkPathLength(); + } + + function syncSvgGeometryAfterRender() { + syncSvgGeometry(); + windowRef.requestAnimationFrame?.(syncSvgGeometry); + } + + function fitInstallSceneButton() { + const background = root.querySelector( + ".pwa-guide-install-control .pwa-guide-accent-fill", + ); + const icon = root.querySelector(".pwa-guide-button-icon"); + const label = root.querySelector(".pwa-guide-button-label"); + const textWidth = label?.getComputedTextLength?.(); + if (!background || !icon || !label || !Number.isFinite(textWidth) || textWidth <= 0) { + return; + } + + const width = getGuideInstallButtonWidth(textWidth); + const x = GUIDE_INSTALL_BUTTON_RIGHT - width; + background.setAttribute("x", String(x)); + background.setAttribute("width", String(width)); + icon.setAttribute("x", String(x + GUIDE_INSTALL_BUTTON_PADDING)); + label.setAttribute("x", String( + x + GUIDE_INSTALL_BUTTON_PADDING + GUIDE_INSTALL_BUTTON_ICON_WIDTH + GUIDE_INSTALL_BUTTON_GAP, + )); + } + + function syncCheckmarkPathLength() { + const checkmark = root.querySelector(".pwa-guide-check-path"); + const length = checkmark?.getTotalLength?.(); + if (!checkmark || !Number.isFinite(length) || length <= 0) { + return; + } + checkmark.style.setProperty("--pwa-guide-check-length", String(length)); + } + + function schedulePlayback() { + clearAutoReplay(); + intersectionObserver?.disconnect(); + intersectionObserver = null; + root.classList.remove("is-playing", "is-complete"); + + if (root.hidden) { + return; + } + if (hasMotionConstraint() || isDocumentHidden()) { + completePlayback(); + return; + } + + if (typeof windowRef.IntersectionObserver !== "function") { + windowRef.requestAnimationFrame(startPlayback); + return; + } + + intersectionObserver = new windowRef.IntersectionObserver((entries) => { + if (!entries.some((entry) => entry.isIntersecting && entry.intersectionRatio >= 0.35)) { + return; + } + intersectionObserver?.disconnect(); + intersectionObserver = null; + startPlayback(); + }, { threshold: [0.35] }); + intersectionObserver.observe(root); + } + + function startPlayback() { + clearAutoReplay(); + if (root.hidden || hasMotionConstraint() || isDocumentHidden()) { + completePlayback(); + return; + } + root.classList.remove("is-playing", "is-complete"); + void root.offsetWidth; + root.classList.add("is-playing"); + } + + function completePlayback({ autoReplay = false } = {}) { + clearAutoReplay(); + intersectionObserver?.disconnect(); + intersectionObserver = null; + root.classList.remove("is-playing"); + root.classList.add("is-complete"); + if (autoReplay) { + scheduleAutoReplay(); + } + } + + function scheduleAutoReplay() { + clearAutoReplay(); + if (root.hidden || hasMotionConstraint() || isDocumentHidden()) { + return; + } + autoReplayTimer = windowRef.setTimeout(() => { + autoReplayTimer = null; + schedulePlayback(); + }, AUTO_REPLAY_DELAY_MS); + } + + function clearAutoReplay() { + if (autoReplayTimer !== null) { + windowRef.clearTimeout(autoReplayTimer); + autoReplayTimer = null; + } + } + + function hasMotionConstraint() { + return Boolean(reducedMotionMedia?.matches) || Boolean(isPowerConstrained?.()); + } + + function handleMotionConstraintChange() { + const constrained = hasMotionConstraint(); + if (root.hidden) { + clearAutoReplay(); + } else if (constrained) { + completePlayback(); + } else if (root.classList.contains("is-complete")) { + scheduleAutoReplay(); + } + } + + function handleVisibilityChange() { + if (root.hidden) { + clearAutoReplay(); + } else if (isDocumentHidden()) { + completePlayback(); + } else if (root.classList.contains("is-complete") && !hasMotionConstraint()) { + scheduleAutoReplay(); + } + } + + function isDocumentHidden() { + return root.ownerDocument?.visibilityState === "hidden"; + } + + root.addEventListener("animationend", (event) => { + if (event.animationName !== "pwa-guide-scene-report") { + return; + } + completePlayback({ autoReplay: true }); + }); + addMediaChangeListener(reducedMotionMedia, handleMotionConstraintChange); + windowRef.addEventListener?.("apk-power-mode-change", handleMotionConstraintChange); + root.ownerDocument?.addEventListener("visibilitychange", handleVisibilityChange); + + return { + dismiss, + hide, + markInstalled, + refresh, + setInstallAvailable, + show, + }; +} + +export function rememberPwaInstallation({ + storage = window.localStorage, + timestamp = Date.now(), +} = {}) { + writeExpiry(storage, INSTALLED_STORAGE_KEY, timestamp + INSTALLED_MARKER_DURATION_MS); +} + +export function getDesktopPlatform(navigatorRef = navigator) { + if (navigatorRef.userAgentData?.mobile) { + return ""; + } + const platform = String( + navigatorRef.userAgentData?.platform || navigatorRef.platform || navigatorRef.userAgent || "", + ).toLowerCase(); + if (platform.includes("mac")) { + return "mac"; + } + if (platform.includes("win")) { + return "windows"; + } + return ""; +} + +export function supportsFileHandling(windowRef = window) { + return typeof windowRef.launchQueue?.setConsumer === "function"; +} + +export function isRunningInstalled(windowRef = window, navigatorRef = navigator) { + const displayModes = ["standalone", "minimal-ui", "window-controls-overlay"]; + return ( + displayModes.some((mode) => windowRef.matchMedia?.(`(display-mode: ${mode})`)?.matches) || + Boolean(navigatorRef.standalone) + ); +} + +function getReducedMotionMedia(windowRef) { + try { + return windowRef.matchMedia?.("(prefers-reduced-motion: reduce)") || null; + } catch { + return null; + } +} + +function addMediaChangeListener(mediaQuery, listener) { + if (typeof mediaQuery?.addEventListener === "function") { + mediaQuery.addEventListener("change", listener); + } else if (typeof mediaQuery?.addListener === "function") { + mediaQuery.addListener(listener); + } +} + +function hasActiveExpiry(storage, key, timestamp) { + try { + const value = Number(storage?.getItem(key)); + return Number.isFinite(value) && value > timestamp; + } catch { + return false; + } +} + +function writeExpiry(storage, key, timestamp) { + try { + storage?.setItem(key, String(timestamp)); + } catch { + // The guide remains usable when storage is blocked or full. + } +} + +function buildGuideHtml(t, { installAvailable }) { + const installHidden = installAvailable ? "" : " hidden"; + const actionsHidden = installAvailable ? "" : " hidden"; + const title = t("pwaGuideTitle"); + const installStep = t("pwaGuideStepInstall"); + const openStep = t("pwaGuideStepOpen"); + const analyzeStep = t("pwaGuideStepAnalyze"); + const openWith = t("pwaGuideMenuOpenWith"); + const reportDone = t("pwaGuideSceneDone"); + + return [ + ``, + `
`, + `${escapeHtml(t("pwaGuideEyebrow"))}`, + `

${escapeHtml(title)}

`, + `

${escapeHtml(t("pwaGuideBody"))}

`, + `
    `, + `
  1. ${escapeHtml(installStep)}
  2. `, + `
  3. ${escapeHtml(openStep)}
  4. `, + `
  5. ${escapeHtml(analyzeStep)}
  6. `, + `
`, + `
`, + ``, + `
`, + `

${escapeHtml(t("pwaGuideManual"))}

`, + `

${escapeHtml(t("pwaGuideFirstOpenNote"))}

`, + `
`, + `
`, + ``, + ``, + `
`, + ].join(""); +} + +function renderInstallScene(sceneLabel, buttonLabel) { + const button = getGuideInstallButtonGeometry(buttonLabel); + return [ + ``, + renderBrowserFrame(), + renderBrandMark(116, 126, 23), + ``, + ``, + ``, + ``, + ``, + `${escapeHtml(buttonLabel)}`, + ``, + ``, + `${escapeHtml(sceneLabel)}`, + ``, + ].join(""); +} + +function getGuideInstallButtonGeometry(label) { + const width = getGuideInstallButtonWidth(estimateGuideTextWidth(label)); + const x = GUIDE_INSTALL_BUTTON_RIGHT - width; + return { + iconX: x + GUIDE_INSTALL_BUTTON_PADDING, + labelX: x + GUIDE_INSTALL_BUTTON_PADDING + GUIDE_INSTALL_BUTTON_ICON_WIDTH + GUIDE_INSTALL_BUTTON_GAP, + width, + x, + }; +} + +function getGuideInstallButtonWidth(textWidth) { + return ( + GUIDE_INSTALL_BUTTON_PADDING * 2 + + GUIDE_INSTALL_BUTTON_ICON_WIDTH + + GUIDE_INSTALL_BUTTON_GAP + + textWidth + ); +} + +function estimateGuideTextWidth(value) { + const width = Array.from(String(value)).reduce((total, character) => { + if (/\s/u.test(character)) { + return total + 3.5; + } + if (/\p{Script=Han}|\p{Script=Hiragana}|\p{Script=Katakana}|\p{Script=Hangul}/u.test(character)) { + return total + 10; + } + return total + (/[A-Z]/u.test(character) ? 6.4 : 5.4); + }, 0); + return Math.round(width * 10) / 10; +} + +function renderOpenWithScene(label, openWith) { + return [ + ``, + renderBrowserFrame(), + ``, + ``, + ``, + ``, + ``, + ``, + ``, + renderBrandMark(263, 157, 9), + `${escapeHtml(openWith)}`, + ``, + ``, + `${escapeHtml(label)}`, + ``, + ``, + ].join(""); +} + +function renderReportScene(label, done) { + return [ + ``, + renderBrowserFrame(), + renderBrandMark(116, 125, 24), + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + `${escapeHtml(`${label} · ${done}`)}`, + ``, + ].join(""); +} + +function renderBrowserFrame() { + return [ + ``, + ``, + ``, + ``, + ``, + ].join(""); +} + +function renderBrandMark(cx, cy, radius) { + const iconSize = radius * 1.18; + const iconX = cx - iconSize / 2; + const iconY = cy - iconSize / 2; + return [ + ``, + ``, + ].join(""); +} + +function renderSharedApk() { + return [ + ``, + ``, + ``, + ``, + `APK`, + ``, + ].join(""); +} diff --git a/packages/apk-webui/src/app/state.js b/packages/apk-webui/src/app/state.js index 8e1a780..8a68980 100644 --- a/packages/apk-webui/src/app/state.js +++ b/packages/apk-webui/src/app/state.js @@ -11,6 +11,7 @@ export function createAppState({ locale, themeChoice, history, historyCollapsed, locale, themeChoice, selectedFile: null, + selectedFileInputSource: "upload", downloadUrl: "", linkStatusKey: "linkIdle", runtimeLogs: [], @@ -170,6 +171,9 @@ export function createRuntimeState() { analyticsContextProvider: () => ({}), analyticsInitialized: false, analyticsLoadScheduled: false, + pwaInstallPrompt: null, + pwaInstallGuideModulePromise: null, + pwaInstallGuideController: null, }; } diff --git a/packages/apk-webui/src/app/view.js b/packages/apk-webui/src/app/view.js index 5bbbef5..e712160 100644 --- a/packages/apk-webui/src/app/view.js +++ b/packages/apk-webui/src/app/view.js @@ -68,6 +68,7 @@ export function collectAppElements(root = document) { resultView: queryAppElement(root, "#result-view"), reportHero: queryAppElement(root, "#report-hero"), reportShareButton: queryAppElement(root, "#report-share-button"), + pwaInstallGuide: queryAppElement(root, "#pwa-install-guide"), archiveDistribution: queryAppElement(root, "#archive-distribution"), tabs: queryAppElement(root, "#tabs"), tabButtons: queryAppElements(root, "#tabs [data-tab]"), diff --git a/packages/apk-webui/src/globals.d.ts b/packages/apk-webui/src/globals.d.ts index 54cde00..c80bd73 100644 --- a/packages/apk-webui/src/globals.d.ts +++ b/packages/apk-webui/src/globals.d.ts @@ -1,6 +1,33 @@ declare const __APK_WEBUI_VERSION__: string; declare const __BOT_REPORT_DATA_ORIGIN__: string; +interface LaunchParams { + files: FileSystemFileHandle[]; + targetURL?: string; +} + +interface LaunchQueue { + setConsumer(consumer: (launchParams: LaunchParams) => void | Promise): void; +} + +interface Window { + launchQueue?: LaunchQueue; +} + +declare var launchQueue: LaunchQueue | undefined; + +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise; + userChoice: Promise<{ + outcome: "accepted" | "dismissed"; + platform: string; + }>; +} + +interface WindowEventMap { + beforeinstallprompt: BeforeInstallPromptEvent; +} + interface Navigator { connection?: { effectiveType?: string; @@ -15,6 +42,7 @@ interface Navigator { modelContext?: { registerTool?: (...args: unknown[]) => unknown; }; + standalone?: boolean; mozConnection?: Navigator["connection"]; userAgentData?: { getHighEntropyValues?: (hints: string[]) => Promise>; diff --git a/packages/apk-webui/src/index.html b/packages/apk-webui/src/index.html index bac8f7b..7c9da39 100644 --- a/packages/apk-webui/src/index.html +++ b/packages/apk-webui/src/index.html @@ -28,6 +28,7 @@ LibChecker WebUI +