Ctrl K
ComunidadGuías…

src/services/pluginVersionRecommendation.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { lessThan, parse } from '@std/semver'

export const UPDATER_PACKAGE_NAME = '@capgo/capacitor-updater'
export const MIN_SUPPORTED_PLUGIN_MAJOR = 5
export const UPDATER_INSTALL_DOCS_URL = 'https://capgo.app/docs/plugins/updater/getting-started/'

const NPM_REGISTRY_URL = 'https://registry.npmjs.org/@capgo%2fcapacitor-updater'
const DIST_TAG_CACHE_MS = 60 * 60 * 1000

export interface PluginVersionRow {
  plugin_version: string
  devices: number
  total_devices: number
}

export interface PluginDistTags {
  latest?: string
  [tag: string]: string | undefined
}

export type PluginVersionStatus = 'current' | 'behind' | 'unsupported' | 'unknown'

export interface PluginMajorStats {
  major: number
  devices: number
  share: number
  latestVersion: string | null
  npmTag: string | null
  installPackage: string | null
  behindDevices: number
  currentDevices: number
  unsupported: boolean
  statusResolved: boolean
}

export interface PluginVersionRecommendationRow extends PluginVersionRow {
  major: number | null
  latestForMajor: string | null
  status: PluginVersionStatus
}

export interface PluginVersionRecommendation {
  dominantVersion: string
  dominantMajor: number | null
  recommendedVersion: string | null
  recommendedTag: string | null
  installPackage: string | null
  installCommand: string | null
  needsUpdate: boolean
  unsupported: boolean
  behindDevices: number
  behindShare: number
  currentDevices: number
  fleetDevices: number
  statusResolved: boolean
  /** True when at least one major has a known latest version, so `behindDevices` counts real devices. */
  behindResolved: boolean
  majors: PluginMajorStats[]
  rows: PluginVersionRecommendationRow[]
}

interface NpmRegistryResponse {
  'dist-tags'?: PluginDistTags
}

let distTagCache: { tags: PluginDistTags, fetchedAt: number } | null = null
let distTagInFlight: Promise<PluginDistTags | null> | null = null

export function parsePluginVersion(version: string) {
  try {
    return parse(version)
  }
  catch {
    return null
  }
}

export function pluginMajorFromVersion(version: string): number | null {
  return parsePluginVersion(version)?.major ?? null
}

export function isUnsupportedPluginMajor(major: number | null): boolean {
  return major === null || major < MIN_SUPPORTED_PLUGIN_MAJOR
}

export function latestTagForMajor(major: number, distTags: PluginDistTags | null): { tag: string, version: string } | null {
  if (!distTags)
    return null

  const latest = distTags.latest
  const latestMajor = latest ? pluginMajorFromVersion(latest) : null
  if (latest && latestMajor === major)
    return { tag: 'latest', version: latest }

  const ltsTag = `lts-v${major}`
  const ltsVersion = distTags[ltsTag]
  if (ltsVersion)
    return { tag: ltsTag, version: ltsVersion }

  return null
}

export function installPackageForTag(tag: string) {
  return `${UPDATER_PACKAGE_NAME}@${tag}`
}

export function installCommandForPackage(installPackage: string) {
  return `npm install ${installPackage}`
}

export function isPluginVersionBehind(version: string, latestVersion: string): boolean {
  const current = parsePluginVersion(version)
  const latest = parsePluginVersion(latestVersion)
  if (!current || !latest)
    return false
  return lessThan(current, latest)
}

// First release per major that sends download_zip_start / download_manifest_start,
// which update_delivery_stats pairs with download_complete to measure delivery time.
const DELIVERY_TIMING_MIN_VERSION_BY_MAJOR: Record<number, string> = {
  5: '5.50.1',
  6: '6.25.2',
  7: '7.26.0',
  8: '8.2.0',
}
const DELIVERY_TIMING_LATEST_MAJOR = 8

export function deliveryTimingMinVersion(major: number | null): string {
  if (major !== null && major > DELIVERY_TIMING_LATEST_MAJOR)
    return `${major}.0.0`
  return DELIVERY_TIMING_MIN_VERSION_BY_MAJOR[major ?? DELIVERY_TIMING_LATEST_MAJOR]
    ?? DELIVERY_TIMING_MIN_VERSION_BY_MAJOR[DELIVERY_TIMING_LATEST_MAJOR]
}

export function supportsDeliveryTiming(version: string): boolean {
  const parsed = parsePluginVersion(version)
  if (!parsed || parsed.major < MIN_SUPPORTED_PLUGIN_MAJOR)
    return false
  return !isPluginVersionBehind(version, deliveryTimingMinVersion(parsed.major))
}

function rowStatus(
  version: string,
  major: number | null,
  latestVersion: string | null,
): PluginVersionStatus {
  if (major === null)
    return 'unknown'
  if (isUnsupportedPluginMajor(major))
    return 'unsupported'
  if (!latestVersion)
    return 'unknown'
  return isPluginVersionBehind(version, latestVersion) ? 'behind' : 'current'
}

export function buildPluginVersionRecommendation(
  versions: PluginVersionRow[],
  distTags: PluginDistTags | null,
): PluginVersionRecommendation | null {
  if (versions.length === 0)
    return null

  const fleetDevices = versions[0]?.total_devices ?? versions.reduce((sum, row) => sum + row.devices, 0)
  const tagsKnown = distTags !== null
  const dominant = versions[0]
  const dominantMajor = pluginMajorFromVersion(dominant.plugin_version)
  const dominantLatest = dominantMajor === null ? null : latestTagForMajor(dominantMajor, distTags)
  const unsupported = tagsKnown && isUnsupportedPluginMajor(dominantMajor) && dominantMajor !== null
  const needsUpdate = unsupported || (
    !!dominantLatest && isPluginVersionBehind(dominant.plugin_version, dominantLatest.version)
  )

  const majorBuckets = new Map<number, PluginVersionRow[]>()
  const rows: PluginVersionRecommendationRow[] = versions.map((row) => {
    const major = pluginMajorFromVersion(row.plugin_version)
    if (major !== null) {
      const bucket = majorBuckets.get(major) ?? []
      bucket.push(row)
      majorBuckets.set(major, bucket)
    }
    const latest = major === null ? null : latestTagForMajor(major, distTags)
    return {
      ...row,
      major,
      latestForMajor: latest?.version ?? null,
      status: rowStatus(row.plugin_version, major, latest?.version ?? null),
    }
  })

  const majors: PluginMajorStats[] = [...majorBuckets.entries()]
    .sort((a, b) => b[1].reduce((sum, row) => sum + row.devices, 0) - a[1].reduce((sum, row) => sum + row.devices, 0) || b[0] - a[0])
    .map(([major, bucket]) => {
      const devices = bucket.reduce((sum, row) => sum + row.devices, 0)
      const latest = latestTagForMajor(major, distTags)
      const unsupportedMajor = isUnsupportedPluginMajor(major)
      const statusResolved = tagsKnown && (unsupportedMajor || latest !== null)
      const behindDevices = statusResolved
        ? bucket.reduce((sum, row) => {
            if (unsupportedMajor || (latest && isPluginVersionBehind(row.plugin_version, latest.version)))
              return sum + row.devices
            return sum
          }, 0)
        : 0
      const currentDevices = statusResolved && latest
        ? bucket.reduce((sum, row) => {
            if (isPluginVersionBehind(row.plugin_version, latest.version))
              return sum
            return sum + row.devices
          }, 0)
        : 0
      return {
        major,
        devices,
        share: fleetDevices > 0 ? (devices / fleetDevices) * 100 : 0,
        latestVersion: latest?.version ?? null,
        npmTag: latest?.tag ?? null,
        installPackage: latest ? installPackageForTag(latest.tag) : null,
        behindDevices,
        currentDevices,
        unsupported: unsupportedMajor,
        statusResolved,
      }
    })

  const behindDevices = majors.reduce((sum, major) => sum + major.behindDevices, 0)
  const currentDevices = majors.reduce((sum, major) => sum + major.currentDevices, 0)
  const statusResolved = tagsKnown && rows.every(row => row.status !== 'unknown')
  const installPackage = dominantLatest && !unsupported
    ? installPackageForTag(dominantLatest.tag)
    : null

  return {
    dominantVersion: dominant.plugin_version,
    dominantMajor,
    recommendedVersion: dominantLatest?.version ?? null,
    recommendedTag: dominantLatest?.tag ?? null,
    installPackage,
    installCommand: installPackage ? installCommandForPackage(installPackage) : null,
    needsUpdate,
    unsupported,
    behindDevices,
    behindShare: fleetDevices > 0 ? (behindDevices / fleetDevices) * 100 : 0,
    currentDevices,
    fleetDevices,
    statusResolved,
    behindResolved: majors.some(major => major.statusResolved),
    majors,
    rows,
  }
}

export async function fetchUpdaterDistTags(): Promise<PluginDistTags | null> {
  const now = Date.now()
  if (distTagCache && now - distTagCache.fetchedAt < DIST_TAG_CACHE_MS)
    return distTagCache.tags
  if (distTagInFlight)
    return distTagInFlight

  distTagInFlight = (async () => {
    let timeout: ReturnType<typeof setTimeout> | undefined
    const signal = typeof AbortSignal.timeout === 'function'
      ? AbortSignal.timeout(10_000)
      : (() => {
          const controller = new AbortController()
          timeout = setTimeout(() => controller.abort(), 10_000)
          return controller.signal
        })()
    try {
      const response = await fetch(NPM_REGISTRY_URL, {
        headers: {
          accept: 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*',
        },
        signal,
      })
      if (!response.ok)
        return null
      const data = await response.json() as NpmRegistryResponse
      const tags = data['dist-tags']
      if (!tags)
        return null
      distTagCache = { tags, fetchedAt: Date.now() }
      return tags
    }
    catch {
      return null
    }
    finally {
      if (timeout)
        clearTimeout(timeout)
      distTagInFlight = null
    }
  })()

  return distTagInFlight
}

export function resetUpdaterDistTagCache() {
  distTagCache = null
  distTagInFlight = null
}