/**
* App 下载逻辑(请求对齐 bili-landpage-client,接口域名见 api/btn.ts)
*
* - 进页面:bottonlist 拿按钮开关(iosDown / iosStore / iosTF / iosH5 / androidButton),不含下载地址
* - 点下载:bottonclick 返回 data.link(地址数组),跳 link[index];同一请求后端记点击
* - iOS 轻量版:点击直接调 h5/config 下载描述文件(不看开关);社媒内置浏览器装不了描述文件,只弹「复制到 Safari」引导
* - iOS 商店版:iosStore(packageType 2) 走 bottonclick 拿 link
* - 下载前把 { ...query, tid } 写入剪贴板供 App 首启归因
*
* 组件接入:
* const { init, download, guide, isSocialInApp } = useAppDownload()
* onMounted(init) // 拉取按钮开关
* 轻量版 / 主包
* 商店包
*/
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { bottonclick, downloadH5, getButtonList } from '~/api/btn'
import type { donwloadParam, H5param } from '~/api/interface'
import { APP_ID } from '~/config/app'
import { copyToClipboard } from '~/utils/clipboard'
import { isSocialInAppBrowser } from '~/utils/isSocialInAppBrowser'
import { renewSessionTraceId } from '~/utils/traceId'
export interface ButtonList {
iosDown?: boolean
iosStore?: boolean
iosTF?: boolean
iosH5?: boolean
iosLink?: string
androidButton?: number
hasChannelApk?: boolean
[key: string]: unknown
}
export type DownloadGuide = 'ios' | 'android' | null
export type Terminal = 'ios' | 'android'
// 模块级单例:PC / 手机组件共用同一份按钮开关,init 只请求一次
const buttonList = ref({})
const ip = ref('')
const ready = ref(false)
let initPromise: Promise | null = null
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent)
const isWeiXin = /MicroMessenger/i.test(navigator.userAgent)
// 去掉空值后序列化(bili filterChannel)
function filterChannel(query: Record) {
const result: Record = {}
Object.entries(query).forEach(([key, value]) => {
if (value) result[key] = value
})
return JSON.stringify(result)
}
function jumpToLink(link: string) {
// 微信内加时间戳,避免命中缓存
window.location.href = isWeiXin ? link + '?time=' + Date.now() : link
}
async function fetchIp() {
try {
const res = await fetch('https://api.ipify.org?format=json')
const data = await res.json()
ip.value = data.ip || ''
} catch (err) {
console.log('获取外部IP失败' + err)
}
}
export function useAppDownload() {
const route = useRoute()
// 当前要展示的安装引导弹层,由页面组件绑定到 IosInstallGuide / AndroidInstallTip
const guide = ref(null)
async function fetchButtonList() {
try {
const { data } = await getButtonList({
appId: APP_ID,
ip: ip.value,
channel: JSON.stringify(route.query),
})
buttonList.value = (data as ButtonList) || {}
ready.value = true
} catch (err) {
console.log('获取按钮列表失败' + JSON.stringify(err))
}
}
function init() {
if (!initPromise) {
initPromise = (async () => {
await fetchIp()
await fetchButtonList()
})()
}
return initPromise
}
// bottonclick 返回的下载地址数组
async function fetchLinks(param: donwloadParam): Promise {
const { data } = await bottonclick(param)
const link = (data as { link?: string[] } | undefined)?.link
return Array.isArray(link) ? link.filter(Boolean) : []
}
async function downloadMobileConfig() {
const paramData: H5param = {}
if (route.query.dc) paramData.dc = String(route.query.dc)
if (route.query.pc) paramData.pc = String(route.query.pc)
const content = await downloadH5(paramData)
const file = new File([content], 'descriptionFile.mobileconfig', {
type: 'application/x-apple-aspen-config',
})
const objectUrl = URL.createObjectURL(file)
const link = document.createElement('a')
link.href = objectUrl
link.download = 'descriptionFile.mobileconfig'
link.click()
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000)
}
// iOS 轻量版:不看 bottonlist 开关,直接调 h5/config 下载描述文件
async function downloadIosLite() {
guide.value = 'ios'
// 社媒内置浏览器无法装描述文件,只展示引导,由页面「复制到 Safari」处理
if (isSocialInAppBrowser()) return true
await downloadMobileConfig()
return true
}
// iOS 商店版:后台开了 iosStore 才有入口,走 bottonclick 拿 link
async function downloadIosStore(param: donwloadParam) {
if (!buttonList.value.iosStore) return false
param.type = 'iosStore'
param.packageType = 2
const links = await fetchLinks(param)
if (!links.length) return false
jumpToLink(links[0])
return true
}
async function downloadAndroid(param: donwloadParam, index: number) {
// 先弹「安卓安装教程」(断网安装、报毒说明),再跳 APK
guide.value = 'android'
const links = await fetchLinks(param)
const target = links[index] || links[0]
if (!target) return false
jumpToLink(target)
return true
}
/**
* 点击下载,返回是否拿到并开始下载(false 时页面可提示稍后重试)
* @param index iOS:0 = 轻量版,1 = 商店包;Android:下载地址下标
* @param terminal 默认按 UA;PC 端弹窗里的 iOS / Android 按钮显式传入
*/
async function download(index = 0, terminal: Terminal = isIos ? 'ios' : 'android') {
const trace_id = renewSessionTraceId()
// 写剪贴板供 App 首次启动读取做安装归因:整包 URL 参数 + tid(海角社区 App 按 tid 读)
copyToClipboard(JSON.stringify({ ...route.query, tid: trace_id }))
if (terminal === 'ios' && index !== 1) {
try {
return await downloadIosLite()
} catch (err) {
console.log('下载描述文件失败' + err)
return false
}
}
await init()
const param: donwloadParam = {
appId: APP_ID,
ip: ip.value,
channel: filterChannel(route.query),
domain: window.location.host || '',
// bili 默认值,安卓请求同样带 iosH5
type: 'iosH5',
terminal,
}
try {
return terminal === 'ios'
? await downloadIosStore(param)
: await downloadAndroid(param, index)
} catch (err) {
console.log('下载失败' + JSON.stringify(err))
return false
}
}
const hasIosStore = computed(() => !!buttonList.value.iosStore)
// 社媒引导页「复制到 Safari」用:当前落地页地址(带渠道参数)
const iosInstallUrl = computed(() => window.location.href)
return {
buttonList,
ready,
isIos,
hasIosStore,
isSocialInApp: isSocialInAppBrowser(),
guide,
iosInstallUrl,
init,
download,
}
}