openapi: 3.1.0
info:
  title: MacFlare API
  version: 0.1.0
  summary: 原生 macOS 状态的单向滑动窗口与快照 API
  description: >-
    单设备协议 v1 快照与 v2 滑动窗口。写入须 Bearer 鉴权，查询公开。/api/now 保留原有必需字段，
    music 增加成对可选 artwork_url/track_url；旧 Agent 可省略，提供时随快照返回。
    先部署接受新字段的 Worker 再更新 Agent；旧 Worker 会拒绝新增字段。
    音乐、前台应用、运行应用、设备分类接口只支持规范 /api/* 路径；每次 GET 各读取一次 KV，不写 KV。
    需要完整状态时只调用一次 /api/now，不要并行轮询全部分类接口。
    buffered 每300秒上传最近900秒窗口，正常延时420秒播放；/api/timeline 每60秒读取，客户端内存回放。
    v2 绝对截止为 window_end 加600秒，原状态接口按服务器时间减420秒投影，无覆盖或缺口返回offline，投影截止为window_end加420秒。
    v1 TTL由服务器配置，默认eco180秒、realtime60秒。KV最终一致可能造成旧值、缓冲或暂时离线；不承诺记录未观测操作或无限补传。
    已知路径支持 OPTIONS 204，仅图标接口支持 HEAD；未知路径或图标返回 404。查询参数不改变行为。
    所有接口设置 CORS *；状态接口使用 no-store，图标 API 成功响应公开缓存 1 小时，不访问 KV。
    原生图标为有限静态清单，与设备在线状态无关；API 请求会执行 Worker，静态 /app-icons/ 路径无需执行。
    示例都是合成数据，发送状态前必须更新 collected_at。图标版权不包含在代码的 MIT 授权中。
  license:
    name: MIT
    identifier: MIT
  contact:
    name: MacFlare contributors
    url: https://github.com/xw7qwq/macflare
servers:
  - url: https://macflare.lucius7.dev
    description: 维护者的公开实例，写入需要维护者的接收令牌
  - url: https://{worker}.{subdomain}.workers.dev
    description: 替换为自己的部署地址
    variables:
      worker:
        default: macflare
      subdomain:
        default: example
  - url: http://localhost:8787
    description: Wrangler 本地开发，默认不连接线上 KV
security: []
paths:
  /api/batch:
    post:
      operationId: uploadWindow
      summary: 验证并覆盖完整滑动窗口
      description: >-
        未压缩UTF-8 JSON，最多524288字节。一次成功请求只写一个KV键，无逐事件写入或跨请求事务性版本仲裁。
        v2时间严格为UTC三位毫秒；generated_at距接收时间最多120秒，window_end不得晚于generated_at且相差最多30秒。
        窗口跨度最多900秒，基线collected_at等于window_start；事件seq严格递增、at非降序，缺口排序且不重叠。
        绝对截止为window_end加600秒，晚到或重复写入不能延长该窗口期限。
        单个Agent负责顺序发送；消费者按会话和批次防旧数据倒退。先部署新Worker再启用buffered Agent。
      security:
        - ingestBearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WindowInput'
      responses:
        '200':
          description: 一次KV写入完成；expires_at为window_end加600秒
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UpdateResult'
        '400':
          description: invalid_json 或 invalid_payload
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: unauthorized
          headers:
            WWW-Authenticate:
              schema: {type: string, const: Bearer}
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '413':
          description: payload_too_large，或非法Content-Length
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '415':
          description: unsupported_media_type，包含压缩Content-Encoding
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          $ref: '#/components/responses/Unavailable'
    options:
      operationId: optionsBatch
      responses:
        '204':
          $ref: '#/components/responses/Preflight'
  /api/timeline:
    get:
      operationId: getTimeline
      summary: 获取窗口、缺口与播放策略，兼容旧快照
      description: >-
        单次GET一次KV读取、无写入，推荐每60秒拉取并在客户端内存按事件时间播放。
        v2 online仅表示窗口尚有效，不表示目标播放时刻已有覆盖；首次暖机、缺口与耗尽需客户端处理。
        v1返回原在线快照并添加mode=snapshot、server_time；原/api/now自身v1形状不变。
        无有效记录返回精确offline对象；KV失败返回503。只有GET和OPTIONS，无HEAD或根路径别名。
      responses:
        '200':
          description: 有效窗口、兼容快照或离线
          headers:
            Cache-Control:
              $ref: '#/components/headers/StatusCacheControl'
            Access-Control-Allow-Origin:
              $ref: '#/components/headers/PublicOrigin'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/WindowStatus'
                  - $ref: '#/components/schemas/SnapshotTimelineStatus'
                  - $ref: '#/components/schemas/OfflineStatus'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '503':
          $ref: '#/components/responses/Unavailable'
    options:
      operationId: optionsTimeline
      responses:
        '204':
          $ref: '#/components/responses/Preflight'
  /api/update:
    post:
      operationId: updateStatus
      summary: 验证并覆盖当前快照
      description: >-
        请求为未压缩 UTF-8 JSON，最多 16384 字节。Bearer 值区分大小写，须等于 INGEST_TOKEN。
        collected_at 距接收时间最多 120 秒；客户端不控制 TTL。每次成功调用写入一个 KV 键。
        鉴权在正文校验之前，绑定有效性检查在鉴权之前；平台配额错误返回通用 503。
      security:
        - ingestBearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StatusInput'
            example:
              schema_version: 1
              collected_at: '2026-09-08T08:00:00.000Z'
              active_app: Visual Studio Code
              running_apps: [Finder, Music, Visual Studio Code]
              battery: {percent: 78, charging: false, power_source: battery}
              system: {load_1m: 1.5, load_5m: 1.8, load_15m: 1.6}
              music: {state: playing, track: Example Song, artist: Example Artist}
      responses:
        '200':
          description: KV 写入成功，已设置服务端配置的 TTL
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UpdateResult'
        '400':
          description: invalid_json 或 invalid_payload
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: unauthorized
          headers:
            WWW-Authenticate:
              schema: {type: string, const: Bearer}
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '413':
          description: payload_too_large，或非法 Content-Length
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '415':
          description: unsupported_media_type，含压缩 Content-Encoding
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          $ref: '#/components/responses/Unavailable'
    options:
      operationId: optionsUpdate
      responses:
        '204':
          $ref: '#/components/responses/Preflight'
  /api/now:
    get:
      operationId: getStatus
      summary: 读取当前公开状态
      description: >-
        v2返回服务器时间减420秒的切片并附playback；未覆盖、缺口、窗口耗尽时offline，expires_at为window_end加420秒。
        v1快照到达expires_at、缺失或损坏时返回offline。
        online 不表示用户在场，也不保证所有指标可用。KV 读取失败返回 503。
        需要多类状态时优先一次调用本接口。music 在 Agent 上报时返回成对可选 artwork_url/track_url，
        未上报时省略；图标对象只在应用分类接口出现。
      responses:
        '200':
          description: 在线快照或离线状态
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/OnlineStatus'
                  - $ref: '#/components/schemas/OfflineStatus'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '503':
          $ref: '#/components/responses/Unavailable'
    options:
      operationId: optionsNow
      responses:
        '204':
          $ref: '#/components/responses/Preflight'
  /api/music:
    get:
      operationId: getMusic
      summary: 读取音乐及可空的 Apple 封面与歌曲链接
      description: >-
        单次GET读取一次KV；v2按延时420秒投影并附playback，缺口或无覆盖时offline；v1沿用快照规则。
        封面与歌曲 URL 直接来自已验证的 Mac 快照，Worker 不查询 Apple，不使用 Cache API 缓存封面。
        两个 URL 字段始终存在，旧 Agent 未上报时为 null，不清空曲名。
        Mac 以原生工具查询，buffered缓存近期曲目、v1缓存当前一首，随窗口或快照上报，不单独增加KV写入。
        仅 GET/OPTIONS，HEAD 405，无根路径别名。
      responses:
        '200':
          description: 分类在线状态或精确的 offline 对象
          headers:
            Cache-Control:
              $ref: '#/components/headers/StatusCacheControl'
            Access-Control-Allow-Origin:
              $ref: '#/components/headers/PublicOrigin'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/MusicStatus'
                  - $ref: '#/components/schemas/OfflineStatus'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '503':
          $ref: '#/components/responses/Unavailable'
    options:
      operationId: optionsMusic
      responses:
        '204':
          $ref: '#/components/responses/Preflight'
  /api/apps/active:
    get:
      operationId: getActiveApp
      summary: 读取前台应用及原生图标地址
      description: >-
        单次GET读取一次KV、不写KV；v2按延时420秒投影并附playback，缺口或无覆盖时offline；v1沿用快照规则。
        未采集或不可用时 active_app 为 null。未知或 System 保留名称，两种图标 URL 为 null。
        icon_url 是相对于部署根域名的静态路径，icon_api_url 是同一 PNG 的 API 路径。
        不向第三方搜索应用名。仅 GET/OPTIONS，HEAD 405，无根路径别名。
      responses:
        '200':
          description: 分类在线状态或精确的 offline 对象
          headers:
            Cache-Control:
              $ref: '#/components/headers/StatusCacheControl'
            Access-Control-Allow-Origin:
              $ref: '#/components/headers/PublicOrigin'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ActiveAppStatus'
                  - $ref: '#/components/schemas/OfflineStatus'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '503':
          $ref: '#/components/responses/Unavailable'
    options:
      operationId: optionsActiveApp
      responses:
        '204':
          $ref: '#/components/responses/Preflight'
  /api/apps/running:
    get:
      operationId: getRunningApps
      summary: 读取运行应用列表及原生图标地址
      description: >-
        单次GET读取一次KV、不写KV；v2按延时420秒投影并附playback，缺口或无覆盖时offline；v1沿用快照规则。
        原快照未采集 running_apps 时返回 null，已采集但无应用时返回空数组；每项名称与图标结构同前台应用。
        未知图标 URL 为 null，不向第三方搜索应用名。仅 GET/OPTIONS，HEAD 405，无根路径别名。
      responses:
        '200':
          description: 分类在线状态或精确的 offline 对象
          headers:
            Cache-Control:
              $ref: '#/components/headers/StatusCacheControl'
            Access-Control-Allow-Origin:
              $ref: '#/components/headers/PublicOrigin'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/RunningAppsStatus'
                  - $ref: '#/components/schemas/OfflineStatus'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '503':
          $ref: '#/components/responses/Unavailable'
    options:
      operationId: optionsRunningApps
      responses:
        '204':
          $ref: '#/components/responses/Preflight'
  /api/device:
    get:
      operationId: getDevice
      summary: 读取电池与系统负载
      description: >-
        单次GET读取一次KV、不写KV；v2按延时420秒投影并附playback，缺口或无覆盖时offline；v1沿用快照规则。
        device.battery 和 device.system 与完整快照结构一致，保留不可用字段的 null。
        系统负载不是 CPU 使用率。仅 GET/OPTIONS，HEAD 405，无根路径别名。
      responses:
        '200':
          description: 分类在线状态或精确的 offline 对象
          headers:
            Cache-Control:
              $ref: '#/components/headers/StatusCacheControl'
            Access-Control-Allow-Origin:
              $ref: '#/components/headers/PublicOrigin'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/DeviceStatus'
                  - $ref: '#/components/schemas/OfflineStatus'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '503':
          $ref: '#/components/responses/Unavailable'
    options:
      operationId: optionsDevice
      responses:
        '204':
          $ref: '#/components/responses/Preflight'
  /api/badge.svg:
    get:
      operationId: getBadge
      summary: 生成公开 SVG 状态徽章
      description: 播放中歌曲优先，其次前台应用或通用在线状态；过期显示 offline。文本 XML 转义并截短。
      responses:
        '200':
          description: SVG 徽章，第三方图片代理可能缓存
          content:
            image/svg+xml:
              schema: {type: string}
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '503':
          $ref: '#/components/responses/Unavailable'
    options:
      operationId: optionsBadge
      responses:
        '204':
          $ref: '#/components/responses/Preflight'
  /api/health:
    get:
      operationId: getHealth
      summary: Worker 路由健康检查
      description: 不访问 KV、不检查 Secret，也不检查 Mac 在线状态。
      responses:
        '200':
          description: Worker 能响应
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [ok, service]
                properties:
                  ok: {type: boolean, const: true}
                  service: {type: string, const: macflare}
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
    options:
      operationId: optionsHealth
      responses:
        '204':
          $ref: '#/components/responses/Preflight'
  /api/icons:
    parameters:
      - $ref: '#/components/parameters/IfNoneMatch'
      - $ref: '#/components/parameters/IfModifiedSince'
    get:
      operationId: getIconManifest
      summary: 读取部署内的原生图标清单
      description: 有限清单，不提供搜索或分页，不代表实时运行列表；不读取 KV 或第三方服务。
      responses:
        '200':
          description: 随站点发布的原生图标映射
          headers:
            Cache-Control:
              $ref: '#/components/headers/IconCacheControl'
            ETag:
              $ref: '#/components/headers/AssetETag'
            Last-Modified:
              $ref: '#/components/headers/AssetLastModified'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IconManifest'
        '304':
          $ref: '#/components/responses/IconNotModified'
        '404':
          $ref: '#/components/responses/IconNotFound'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '503':
          $ref: '#/components/responses/Unavailable'
    head:
      operationId: headIconManifest
      summary: 读取图标清单的状态与响应头，不返回正文
      responses:
        '200':
          $ref: '#/components/responses/IconHead'
        '304':
          $ref: '#/components/responses/IconNotModified'
        '404':
          description: not_found；清单不存在，无正文
        '503':
          description: service_unavailable；站点资源绑定不可用，无正文
    options:
      operationId: optionsIconManifest
      responses:
        '204':
          $ref: '#/components/responses/IconPreflight'
  /api/icons/{id}.png:
    parameters:
      - name: id
        in: path
        required: true
        description: 使用图标清单中实际存在的 id，区分大小写
        schema:
          type: string
          pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$'
        example: visual-studio-code
      - $ref: '#/components/parameters/IfNoneMatch'
      - $ref: '#/components/parameters/IfModifiedSince'
    get:
      operationId: getIconPng
      summary: 返回原生 PNG，可直接用于 img src
      description: 不读取 KV 或第三方服务，Mac 离线仍可访问；相同资源静态路径为 /app-icons/{id}.png。
      responses:
        '200':
          description: 原生应用图标，版权归各自软件作者
          headers:
            Cache-Control:
              $ref: '#/components/headers/IconCacheControl'
            ETag:
              $ref: '#/components/headers/AssetETag'
            Last-Modified:
              $ref: '#/components/headers/AssetLastModified'
          content:
            image/png:
              schema: {type: string, format: binary}
        '304':
          $ref: '#/components/responses/IconNotModified'
        '404':
          $ref: '#/components/responses/IconNotFound'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '503':
          $ref: '#/components/responses/Unavailable'
    head:
      operationId: headIconPng
      summary: 读取 PNG 的状态与响应头，不返回正文
      responses:
        '200':
          $ref: '#/components/responses/IconHead'
        '304':
          $ref: '#/components/responses/IconNotModified'
        '404':
          description: not_found；图标不存在，无正文
        '503':
          description: service_unavailable；站点资源绑定不可用，无正文
    options:
      operationId: optionsIconPng
      responses:
        '204':
          $ref: '#/components/responses/IconPreflight'
components:
  securitySchemes:
    ingestBearer:
      type: http
      scheme: bearer
      description: >-
        独立 INGEST_TOKEN，32–1024 字符且不含空白，推荐32字节随机值的64位十六进制表示。
        与 Cloudflare 账户 API Token 不同，禁止放入前端、URL 或仓库。
  parameters:
    IfNoneMatch:
      name: If-None-Match
      in: header
      required: false
      description: 传递给静态资源层的 ETag 条件，未修改时可返回 304
      schema: {type: string}
    IfModifiedSince:
      name: If-Modified-Since
      in: header
      required: false
      description: 传递给静态资源层的 HTTP 日期条件
      schema: {type: string}
  headers:
    StatusCacheControl:
      schema: {type: string, const: 'no-store, max-age=0'}
    PublicOrigin:
      schema: {type: string, const: '*'}
    IconCacheControl:
      schema: {type: string, const: 'public, max-age=3600'}
    AssetETag:
      description: 静态资源层提供时保留，浏览器跨域可读取
      schema: {type: string}
    AssetLastModified:
      description: 静态资源层提供时保留，浏览器跨域可读取
      schema: {type: string}
  responses:
    IconHead:
      description: 与 GET 相同的资源响应头，无正文
      headers:
        Cache-Control:
          $ref: '#/components/headers/IconCacheControl'
        ETag:
          $ref: '#/components/headers/AssetETag'
        Last-Modified:
          $ref: '#/components/headers/AssetLastModified'
    IconNotModified:
      description: 条件请求未修改，无正文
      headers:
        Cache-Control:
          $ref: '#/components/headers/IconCacheControl'
        ETag:
          $ref: '#/components/headers/AssetETag'
        Last-Modified:
          $ref: '#/components/headers/AssetLastModified'
    IconNotFound:
      description: not_found；未知或缺失的图标资源
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example: {error: not_found}
    IconPreflight:
      description: 图标接口 CORS 预检，无正文，无需鉴权
      headers:
        Access-Control-Allow-Origin:
          schema: {type: string, const: '*'}
        Access-Control-Allow-Methods:
          schema: {type: string, const: 'GET, HEAD, OPTIONS'}
        Access-Control-Allow-Headers:
          schema: {type: string, const: 'If-None-Match, If-Modified-Since'}
        Access-Control-Expose-Headers:
          schema: {type: string, const: 'ETag, Last-Modified'}
    Preflight:
      description: 已知路径的 CORS 预检，无正文，无需鉴权
      headers:
        Access-Control-Allow-Origin:
          schema: {type: string, const: '*'}
        Access-Control-Allow-Methods:
          schema: {type: string, const: 'GET, POST, OPTIONS'}
        Access-Control-Allow-Headers:
          schema: {type: string, const: 'Authorization, Content-Type'}
    MethodNotAllowed:
      description: method_not_allowed；已知路径使用不支持的方法，允许方法依路径确定
      headers:
        Allow:
          schema: {type: string, enum: ['GET, OPTIONS', 'POST, OPTIONS', 'GET, HEAD, OPTIONS']}
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unavailable:
      description: service_unavailable；所需配置无效、存储或站点资源访问失败，不暴露平台内部详情
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  schemas:
    MillisecondTimestamp:
      type: string
      format: date-time
      pattern: '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$'
      description: 规范UTC时间，必须精确保留3位毫秒
    Playback:
      type: object
      additionalProperties: false
      required: [mode, delay_seconds, at, window_end]
      properties:
        mode: {type: string, const: delayed}
        delay_seconds: {type: integer, const: 420}
        at:
          $ref: '#/components/schemas/MillisecondTimestamp'
          description: 服务器当前时间减420秒的投影时刻
        window_end:
          $ref: '#/components/schemas/MillisecondTimestamp'
    WindowPolicy:
      type: object
      additionalProperties: false
      required: [upload_interval_seconds, window_seconds, playback_delay_seconds, poll_interval_seconds, sample_interval_seconds, metrics_interval_seconds]
      properties:
        upload_interval_seconds: {type: integer, const: 300}
        window_seconds: {type: integer, const: 900}
        playback_delay_seconds: {type: integer, const: 420}
        poll_interval_seconds: {type: integer, const: 60}
        sample_interval_seconds: {type: integer, const: 2}
        metrics_interval_seconds: {type: integer, const: 30}
    WindowChanges:
      type: object
      additionalProperties: false
      minProperties: 1
      description: 非空顶层字段替换，沿用v1类型；不是对象内深层合并，music须完整替换
      properties:
        active_app:
          $ref: '#/components/schemas/NullableAppName'
        running_apps:
          $ref: '#/components/schemas/SnapshotFields/properties/running_apps'
        battery:
          $ref: '#/components/schemas/Battery'
        system:
          $ref: '#/components/schemas/System'
        music:
          $ref: '#/components/schemas/Music'
    WindowEvent:
      type: object
      additionalProperties: false
      required: [seq, at, changes]
      properties:
        seq:
          type: integer
          minimum: 1
          maximum: 9007199254740991
          description: 会话内正安全整数；同包严格递增
        at:
          $ref: '#/components/schemas/MillisecondTimestamp'
          description: 本机观测时间，在窗口内且非降序；时间相同按seq排序
        changes:
          $ref: '#/components/schemas/WindowChanges'
    WindowGap:
      type: object
      additionalProperties: false
      required: [start_at, end_at, reason]
      description: 窗口内半开区间[start_at,end_at)，起点小于终点；数组中按时间排序且不重叠
      properties:
        start_at:
          $ref: '#/components/schemas/MillisecondTimestamp'
        end_at:
          $ref: '#/components/schemas/MillisecondTimestamp'
        reason:
          type: string
          enum: [sleep, restart, collection, overflow, clock]
    WindowInput:
      type: object
      additionalProperties: false
      required: [schema_version, session_id, batch_seq, generated_at, window_start, window_end, baseline, events, gaps, dropped_events]
      description: >-
        完整自包含窗口；所有时间关系、数组顺序与最大正文大小另由Worker校验。
        window_start<=window_end、跨度<=900秒；window_end<=generated_at且差<=30秒；generated_at距服务器接收时间<=120秒。
        baseline.collected_at精确等于window_start，不适用v1入口的当前时间正负120秒限制。
      properties:
        schema_version: {type: integer, const: 2}
        session_id:
          type: string
          pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
          description: 小写UUID形式；隐私配置变化时清队列并更换会话
        batch_seq:
          type: integer
          minimum: 1
          maximum: 9007199254740991
          description: 会话内批次序号，消费端防止旧KV结果使播放倒退
        generated_at:
          $ref: '#/components/schemas/MillisecondTimestamp'
        window_start:
          $ref: '#/components/schemas/MillisecondTimestamp'
        window_end:
          $ref: '#/components/schemas/MillisecondTimestamp'
        baseline:
          allOf:
            - $ref: '#/components/schemas/StatusInput'
            - type: object
              properties:
                collected_at:
                  $ref: '#/components/schemas/MillisecondTimestamp'
          description: 起点完整v1快照；collected_at精确等于window_start
        events:
          type: array
          maxItems: 2048
          items:
            $ref: '#/components/schemas/WindowEvent'
        gaps:
          type: array
          maxItems: 128
          items:
            $ref: '#/components/schemas/WindowGap'
        dropped_events:
          type: integer
          minimum: 0
          maximum: 9007199254740991
    WindowStatus:
      type: object
      additionalProperties: false
      required: [status, mode, server_time, updated_at, expires_at, policy, window]
      properties:
        status: {type: string, const: online}
        mode: {type: string, const: window}
        server_time:
          $ref: '#/components/schemas/MillisecondTimestamp'
        updated_at:
          $ref: '#/components/schemas/UtcTimestamp'
          description: Worker接收这一包的时间
        expires_at:
          $ref: '#/components/schemas/MillisecondTimestamp'
          description: window_end加600秒；并不表示播放头一直有观测覆盖
        policy:
          $ref: '#/components/schemas/WindowPolicy'
        window:
          $ref: '#/components/schemas/WindowInput'
    SnapshotTimelineStatus:
      allOf:
        - $ref: '#/components/schemas/SnapshotFields'
        - type: object
          required: [status, mode, server_time, updated_at, expires_at]
          properties:
            status: {type: string, const: online}
            mode: {type: string, const: snapshot}
            server_time:
              $ref: '#/components/schemas/MillisecondTimestamp'
            updated_at:
              $ref: '#/components/schemas/UtcTimestamp'
            expires_at:
              $ref: '#/components/schemas/UtcTimestamp'
      unevaluatedProperties: false
    SliceEnvelope:
      type: object
      required: [status, updated_at, expires_at, collected_at]
      properties:
        status: {type: string, const: online}
        updated_at:
          $ref: '#/components/schemas/UtcTimestamp'
          description: 服务端接收时间
        expires_at:
          $ref: '#/components/schemas/UtcTimestamp'
          description: 当前有效的服务端截止时间，不受封面缓存延长
        collected_at:
          $ref: '#/components/schemas/UtcTimestamp'
          description: v1本机采集时间；v2为最后已应用事件时刻或基线时刻，不等于playback.at
        playback:
          $ref: '#/components/schemas/Playback'
          description: 仅v2投影在线时存在
    MusicWithArtwork:
      allOf:
        - $ref: '#/components/schemas/Music'
        - type: object
          required: [artwork_url, track_url]
      description: 音乐分类接口始终返回 URL 字段；旧快照缺省时补为双 null
    AppWithIcon:
      type: object
      additionalProperties: false
      required: [name, icon_url, icon_api_url]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          pattern: '^[^\u0000-\u001f\u007f]*$'
          description: 本机过滤后的公开应用名，未知图标也保留名称
          example: Visual Studio Code
        icon_url:
          type: [string, 'null']
          pattern: '^/app-icons/[a-z0-9]+(?:-[a-z0-9]+)*\.png$'
          description: 相对于部署根域名的静态 PNG，未知或 System 为 null；不执行 Worker
          example: /app-icons/visual-studio-code.png
        icon_api_url:
          type: [string, 'null']
          pattern: '^/api/icons/[a-z0-9]+(?:-[a-z0-9]+)*\.png$'
          description: 相对于部署根域名的同图标 API 路径，未知或 System 为 null；请求计 Worker
          example: /api/icons/visual-studio-code.png
    MusicStatus:
      allOf:
        - $ref: '#/components/schemas/SliceEnvelope'
        - type: object
          required: [music]
          properties:
            music:
              $ref: '#/components/schemas/MusicWithArtwork'
      unevaluatedProperties: false
    ActiveAppStatus:
      allOf:
        - $ref: '#/components/schemas/SliceEnvelope'
        - type: object
          required: [active_app]
          properties:
            active_app:
              oneOf:
                - $ref: '#/components/schemas/AppWithIcon'
                - type: 'null'
              description: 未采集或不可用时为 null
      unevaluatedProperties: false
    RunningAppsStatus:
      allOf:
        - $ref: '#/components/schemas/SliceEnvelope'
        - type: object
          required: [running_apps]
          properties:
            running_apps:
              type: [array, 'null']
              maxItems: 64
              uniqueItems: true
              items:
                $ref: '#/components/schemas/AppWithIcon'
              description: 未采集为 null，采集但无应用为 []
      unevaluatedProperties: false
    DeviceStatus:
      allOf:
        - $ref: '#/components/schemas/SliceEnvelope'
        - type: object
          required: [device]
          properties:
            device:
              type: object
              additionalProperties: false
              required: [battery, system]
              properties:
                battery:
                  $ref: '#/components/schemas/Battery'
                system:
                  $ref: '#/components/schemas/System'
      unevaluatedProperties: false
    IconManifest:
      type: object
      additionalProperties: false
      required: [version, icons]
      properties:
        version: {type: integer, const: 1}
        icons:
          type: array
          description: 随部署发布的有限图标集合，不随实时运行列表变化
          items:
            $ref: '#/components/schemas/InstalledAppIcon'
    InstalledAppIcon:
      type: object
      additionalProperties: false
      required: [id, app, aliases, imageUrl, source, credit, sourceUrl]
      properties:
        id:
          type: string
          pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$'
          example: visual-studio-code
        app:
          type: string
          minLength: 1
          example: Visual Studio Code
        aliases:
          type: array
          minItems: 1
          items: {type: string, minLength: 1}
          example: [Code, Visual Studio Code]
        imageUrl:
          type: string
          pattern: '^/api/icons/[a-z0-9]+(?:-[a-z0-9]+)*\.png$'
          description: 相对于部署根域名的 PNG API 路径，id 与该记录一致
          example: /api/icons/visual-studio-code.png
        source:
          type: string
          const: installed-app
        credit:
          type: string
          minLength: 1
          example: 应用图标版权归原作者
        sourceUrl:
          type: [string, 'null']
          format: uri
          pattern: '^https://'
          description: 可用时为官方 HTTPS 来源地址，否则为 null
          example: null
        sha256:
          type: string
          pattern: '^[a-f0-9]{64}$'
          description: 可选的 PNG 文件 SHA-256 摘要
    UtcTimestamp:
      type: string
      format: date-time
      pattern: '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$'
      description: UTC ISO 8601，以 Z 结尾，小数秒可省略或保留1–3位
    NullableAppName:
      type: [string, 'null']
      minLength: 1
      maxLength: 200
      pattern: '^[^\u0000-\u001f\u007f]*$'
    NullableMusicText:
      type: [string, 'null']
      minLength: 1
      maxLength: 500
      pattern: '^[^\u0000-\u001f\u007f]*$'
    NullableLoad:
      type: [number, 'null']
      minimum: 0
      maximum: 100000
      description: 负载平均值，不是 CPU 百分比；JSON 数值必须有限
    Battery:
      type: object
      additionalProperties: false
      required: [percent, charging, power_source]
      properties:
        percent:
          type: [number, 'null']
          minimum: 0
          maximum: 100
        charging:
          type: [boolean, 'null']
          description: 接通电源不等于正在充电
        power_source:
          type: string
          enum: [ac, battery, unknown]
    System:
      type: object
      additionalProperties: false
      required: [load_1m, load_5m, load_15m]
      properties:
        load_1m:
          $ref: '#/components/schemas/NullableLoad'
        load_5m:
          $ref: '#/components/schemas/NullableLoad'
        load_15m:
          $ref: '#/components/schemas/NullableLoad'
    AppleArtworkUrl:
      type: string
      maxLength: 2048
      format: uri
      pattern: '^[Hh][Tt][Tt][Pp][Ss]://[^\u0000-\u001f\u007f]*$'
      description: >-
        按 Unicode 码点计最多 2048 字符，无首尾空白或 ASCII 控制字符。
        Worker 按 URL 标准解析后要求 HTTPS、mzstatic.com 的子域，无用户信息、无非默认端口；显式 443 可用。
        Mac 严格匹配当前歌名和歌手后采用 Apple 返回的原始 artworkUrl100，不改写分辨率。
    AppleTrackUrl:
      type: string
      maxLength: 2048
      format: uri
      pattern: '^[Hh][Tt][Tt][Pp][Ss]://[^\u0000-\u001f\u007f]*$'
      description: >-
        按 Unicode 码点计最多 2048 字符，无首尾空白或 ASCII 控制字符。
        Worker 按 URL 标准解析后要求 HTTPS、域名为 itunes.apple.com 或 music.apple.com，
        无用户信息、无非默认端口；显式 443 可用。
    Music:
      type: object
      additionalProperties: false
      required: [state, track, artist]
      dependentRequired:
        artwork_url: [track_url]
        track_url: [artwork_url]
      description: >-
        URL 字段成对可选：同时省略、同时为 null 或同时为安全字符串。
        非空 URL 仅用于 playing/paused 且 track 与 artist 去除首尾空白后均非空。
        旧 Agent 可继续省略；Worker 不补搜。必须先升级 Worker 再更新上报新字段的 Agent。
      properties:
        state:
          type: string
          enum: [playing, paused, stopped, unavailable]
        track:
          $ref: '#/components/schemas/NullableMusicText'
        artist:
          $ref: '#/components/schemas/NullableMusicText'
        artwork_url:
          oneOf:
            - $ref: '#/components/schemas/AppleArtworkUrl'
            - type: 'null'
          description: 可选封面 URL，必须与 track_url 同时提供
        track_url:
          oneOf:
            - $ref: '#/components/schemas/AppleTrackUrl'
            - type: 'null'
          description: 可选歌曲 URL，必须与 artwork_url 同时提供
      allOf:
        - if:
            required: [artwork_url]
          then:
            oneOf:
              - properties:
                  artwork_url: {type: 'null'}
                  track_url: {type: 'null'}
              - properties:
                  artwork_url: {type: string}
                  track_url: {type: string}
                  state: {enum: [playing, paused]}
                  track: {type: string, pattern: '\S'}
                  artist: {type: string, pattern: '\S'}
    SnapshotFields:
      type: object
      required: [schema_version, collected_at, active_app, battery, system, music]
      properties:
        schema_version:
          type: integer
          const: 1
        collected_at:
          $ref: '#/components/schemas/UtcTimestamp'
          description: v1入口须位于服务端时间正负120秒以内；v2基线等于window_start，投影为最后应用事件或基线时刻
        active_app:
          $ref: '#/components/schemas/NullableAppName'
        running_apps:
          type: array
          maxItems: 64
          uniqueItems: true
          description: 可选 GUI 应用名称列表，敏感条目已在本机过滤；关闭时省略
          items:
            type: string
            minLength: 1
            maxLength: 200
            pattern: '^[^\u0000-\u001f\u007f]*$'
        battery:
          $ref: '#/components/schemas/Battery'
        system:
          $ref: '#/components/schemas/System'
        music:
          $ref: '#/components/schemas/Music'
    StatusInput:
      allOf:
        - $ref: '#/components/schemas/SnapshotFields'
      unevaluatedProperties: false
    UpdateResult:
      type: object
      additionalProperties: false
      required: [ok, updated_at, expires_at]
      properties:
        ok: {type: boolean, const: true}
        updated_at:
          $ref: '#/components/schemas/UtcTimestamp'
          description: 服务端接收时间
        expires_at:
          $ref: '#/components/schemas/UtcTimestamp'
          description: v1通常为eco接收时间加180秒或realtime加60秒；v2批量写入为window_end加600秒
    OnlineStatus:
      allOf:
        - $ref: '#/components/schemas/SnapshotFields'
        - type: object
          required: [status, updated_at, expires_at]
          properties:
            status: {type: string, const: online}
            updated_at:
              $ref: '#/components/schemas/UtcTimestamp'
            expires_at:
              $ref: '#/components/schemas/UtcTimestamp'
              description: v2投影为window_end加420秒；v1为快照有效截止
            playback:
              $ref: '#/components/schemas/Playback'
              description: 仅v2投影在线时存在
      unevaluatedProperties: false
    OfflineStatus:
      type: object
      additionalProperties: false
      required: [status]
      properties:
        status: {type: string, const: offline}
      example: {status: offline}
    Error:
      type: object
      additionalProperties: false
      required: [error]
      properties:
        error:
          type: string
          enum:
            - invalid_json
            - invalid_payload
            - unauthorized
            - not_found
            - method_not_allowed
            - payload_too_large
            - unsupported_media_type
            - service_unavailable
