[GitHub Action] 整合 GitHub Action 在 Self Host Runner - iOS 篇

整合 GitHub Action 在 Self Host Runner - iOS 篇

準备 Runner 环境

CICD 建置 - 整合 GitHub Action - iOS 篇

準备 Runner 环境

  1. 取得 Apple Developer Account (Apple Developer)
  2. 安装 Xcode
  3. 安装 Xcode Command Line Tools
    • xcode-select --install
  4. 安装 Cocoapods
    • brew install cocoapods

準备及新增建置时候需要的 secrets 和 variable 到 GitHub 中

要能够单纯化 runner 建置的环境,避免要手动要放置各种档案到特定位置后才可以建置,所以把必要的档案、变数、密码等等都放到 GitHub 当作环境变数,然后在 Flow 执行的过程中能够动态自动的产生或取得

可以到 repo 的 Settings 中,左方的 Secrets and variables 点下后选 Actions 来管理 secrets 和 variable

secrets, variable 的名称均可以自行讨论决定

要取得及新增的项目如下

  1. 建置相关
    • Apple Developer Certificate 档案 (p12) 及 档案密码
    • [不需要了,保留记录用] Mobile provisioning profile for the app
    • ExportOptions.plist
    • 一个建立建置时使用的 Keychain 的密码
    • worksapce name
    • app name
    • scheme name
  2. 上传到 Test flight 的 App Store Connect API 相关
    • App Store Connect API Issuer ID
    • App Store Connvet API Key ID
    • App Store Connect API Private key 档案 (p8)

下面分别就各项东西怎么準备说明

Apple Developer Certificate 档案 (p12) 及 档案密码

取得档案步骤

汇出步骤

  1. 打开 Keychain App
  2. 点选左边 登入
  3. 切换到 凭证 页签
  4. 找到 Appple Development 开头的凭证
  5. 在上麵右键点选 输出

会出时会要求你设定密码,这个密码就是要使用该档案的 档案密码 然后成功会出的 p12 档案就是 Apple Developer Certificate 档案 (p12)

新增 Secrets

BUILD_CERTIFICATE_BASE64

假设汇出的凭证 p12 档案名称是 Certificates.p12

使用指令

base64 -i Certificates.p12 | pbcopy

将档案转换成 base64 并且複製到记忆体后,贴在该 secrets 的内容

P12_PASSWORD

将汇出凭证使用的密码键入

[不需要了,保留记录用] Mobile provisioning profile for the app

取得方式

  • Apple Developer 里面产生
    • Reference: Apple Developer — Certificates & Provisioning Profiles
  • 从开发者本机取得
    • 路径是:~/Library/Developer/Xcode/UserData/Provisioning Profiles
    • 旧一点版本的 xcode 会放置于:~/Library/MobileDevice/Provisioning Profiles

每个 App 会有各自的,所以不同 App 要使用不同的档案,所以要找取得对的档案,可以用用文字编辑器打开档案看里面的资讯,可以知道哪一个是你要的

上述的方式均可以取得 mobile provision 的档案,但其实 mobile provision 是有期限的,根据 Xcode Provisioning Profile Automation for CI 后来 Apple 有出一个可以自动更新 provision profile 的方式,只要在 project 档案打勾,并且在下指令 archive 的时候补上一个参数即可

备注:文章中说的参数是 -allowProvisioningUpdates 但文章中的範例有错误,这个参数只能下在 archive 的时候,如果下在 exportArchive 的话,CI 执行到最后会出现 export ipa 成功但那个 step 会失败因为不认得 -allowProvisioningUpdates 是什么

新增 secrets

BUILD_PROVISION_PROFILE_BASE64

假设 mobileprovision 档案名称是 1d0e8da1-9eba-41c7-a308-931ba380c3b0.mobileprovision

base64 -i 1d0e8da1-9eba-41c7-a308-931ba380c3b0.mobileprovision | pbcopy

将档案转换成 base64 并且複製到记忆体后,贴在该 secrets 的内容

ExportOptions.plist

取得方式

ExportOptions.plist 需要先使用 xcode 产生,可以透过 xcode 执行 archive 然后 distribute,在 distribute 的时候选 Release Testing ( 主要是要使用 ad-hoc 的方式 ) 去产生 IPA 档案后,在输出的目录就会得到一个 ExportOptions.plist

不同的 scheme 有可能会需要不同的 ExportOptions.plist,因为 bundler id 是不同的,意思就是如果同一个用不同的 scheme 分正式版和开发版,并且里面的 bundler 是不同的,那就要产生两份 ExportOptions.plist 使用

新增 secrets

EXPORT_OPTIONS_PLIST

使用指令

base64 -i ExportOptions.plist | pbcopy

将档案转换成 base64 并且複製到记忆体后,贴在该 secrets 的内容

一个建立建置时使用的 Keychain 的密码

取得方式

自行决定一个强密码

新增 secrets

KEYCHAIN_PASSWORD

将密码键入

其他

新增 variables

IOS_APP_NAME

用于 build archive, export ipa 的名称

IOS_SCHEME_NAME

建置的时候要使用的 scheme name,仅需名称不用副档名

IOS_WORKSPACE_NAME

建置的时候要使用的 workspace name,仅需名称不用副档名

App Store Connect API 相关

取得方式

可以看 Generating Tokens for API Requests 以及 Creating API Keys for App Store Connect API 来产生及建立 Apple Store Connect API Key

大致上步骤如下

开启 App Store Connect 网站 后,点选 使用者与存取权限,然后点上方的 整合 后就可以看到左边的一排的功能列出现有 App Store Connect API (预设)

  • 在画面上可以看到 Issuer ID 下面呈现的一串字串就是 App Store Connect API Issuer ID
  • 下方的 API Key 的表格可上就可以看到 金钥 ID 栏位,该栏位所呈现的就是 App Store Connvet API Key ID
  • App Store Connect API Private key 档案 (p8) 仅在刚建立 API Key 的最后步骤可以下载,建立之后就无法再次下载,所以请谨慎保存

Private key 预设的档名是 Auth_xxxx.p8 在上传到 test flight 的时候要有一模一样的名称的档案存在一些规範中的路径才可以被找到

新增 secrets

APP_STORE_CONNECT_API_ISSUER_ID

填入(複製贴上) App Store Connect API Issuer ID 的值

APP_STORE_CONNECT_API_KEY_ID

填入(複製贴上) App Store Connvet API Key ID

APP_STORE_CONNECT_API_PRIVATE_KEY

假设下载的 private key 档案名称是 Auth_1234.p8

使用指令

base64 -i Auth_1234.p8 | pbcopy

将档案转换成 base64 并且複製到记忆体后,贴在该 secrets 的内容

準备 GitHub Action 要用的 YAML 档案

新增 YAML 档案

  1. 在 repo 的根目录新增 .github/workflows 共 2 个资料夹
  2. .github/workflows 的资料夹中新增 {YOUR_WORKFLOW_NAME}.yaml 档案

要让 github action 有作用,需要在 default branch 新增这个 yaml 档案,所以开发测试角度来说可以先建立一个空的,然后再开 branch 去调整

可以根据 Writing workflows 来写 Workflow YAML,这边提供 iOS 的範例,可以再依照需求作调整

YAML 的内容

这边以我专案使用的 YAML 举例,说明 YAML 中哪些 step 对于建置 iOS App 时候哪些是必要的,以及额外补充说明一些用 Gtihub action 的心得技巧

name: "Build iOS app"

on:
  # allow manual trigger
  workflow_dispatch:
  # trigger push to main branch with tag start with "v"
  push:
    branches:
      - main
      - develop
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+*'
      - 'dev[0-9]+.[0-9]+.[0-9]+*'
  # trigger pull request for all branches    
  pull_request:
    types: [opened, synchronize, reopened]
    branches:
      - '*'

jobs:
  build_and_upload:
    runs-on: [self-hosted, macOS]
    steps:
      # this was more debug as was curious what came pre-installed
      # GitHub shares this online, e.g. https://github.com/actions/runner-images/blob/macOS-12/20230224.1/images/macos/macos-12-Readme.md
      - name: Check Xcode version
        run: /usr/bin/xcodebuild -version

      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Get branch name (push branch)
        if: github.ref_type == 'branch'
        run: |
          echo "CURRENT_BRANCH=${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}" >> $GITHUB_ENV
          echo "CURRENT_TAG=" >> $GITHUB_ENV

      - name: Get branch name (push tag)
        if: github.ref_type == 'tag'
        run: |
          echo "CURRENT_TAG=${{ github.ref_name }}" >> $GITHUB_ENV
          
          BRANCHES=$(git branch -r --contains ${{ github.ref }} | grep -v HEAD | tr -d ' ' | sed 's/origin\///')
          if echo "$BRANCHES" | grep -q "main"; then
            CURRENT_BRANCH="main"
          else
            CURRENT_BRANCH=$(echo "$BRANCHES" | awk 'NR==1{print $1}')
          fi

          echo "CURRENT_BRANCH=$CURRENT_BRANCH" >> $GITHUB_ENV

      - name: Current branch name and tag name
        env:
          CURRENT_BRANCH: ${{ env.CURRENT_BRANCH }}
          CURRENT_TAG: ${{ env.CURRENT_TAG }}
        run: |
          echo "Current Branch: $CURRENT_BRANCH. Current Tag: $CURRENT_TAG"          

      - name: Install the Apple certificate and provisioning profile
        env:
          BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
          P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: |
          # Store original keychain settings
          ORIGINAL_KEYCHAIN=$(security default-keychain -d user | xargs)
          echo "ORIGINAL_KEYCHAIN=$ORIGINAL_KEYCHAIN" >> $GITHUB_ENV
          
          # create variables
          BUILD_CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
          PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db

          # import certificate and provisioning profile from secrets
          echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $BUILD_CERTIFICATE_PATH

          # create temporary keychain
          security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH

          # import certificate to keychain
          security import $BUILD_CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          security list-keychain -d user -s $KEYCHAIN_PATH

          # 避免弹出 Keychain UI 问你密码
          security set-key-partition-list -S apple-tool:,apple: -s -k $KEYCHAIN_PASSWORD $KEYCHAIN_PATH

      - name: Create App Store Connect API Key File
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY }}
        run: |
          # 要把 p8 档案放置于 private_keys 资料夹下,才可以让 xcodebuild altool 的时候方便使用(不需要特别指定参数)
          APP_STORE_CONNECT_API_PRIVATE_KEY_PATH=./private_keys/AuthKey_$APP_STORE_CONNECT_API_KEY_ID.p8

          # import app store connect api private key from secrets
          mkdir -p private_keys
          echo -n "$APP_STORE_CONNECT_API_PRIVATE_KEY" | base64 --decode -o $APP_STORE_CONNECT_API_PRIVATE_KEY_PATH

          # transform relative path to absolute path
          APP_STORE_CONNECT_API_PRIVATE_KEY_PATH=$(realpath $APP_STORE_CONNECT_API_PRIVATE_KEY_PATH)
          echo "APP_STORE_CONNECT_API_PRIVATE_KEY_PATH=$APP_STORE_CONNECT_API_PRIVATE_KEY_PATH" >> $GITHUB_ENV
      
      - name: Clean Derived Data (before build)
        run: rm -rf ~/Library/Developer/Xcode/DerivedData/*
            
      - name: Build archive
        env:
          IOS_APP_NAME: ${{ vars.IOS_APP_NAME }}
          IOS_SCHEME_NAME_PRODUCTION: ${{ vars.IOS_SCHEME_NAME }}
          IOS_SCHEME_NAME_DEV: ${{ vars.IOS_SCHEME_NAME_DEV }}
          IOS_WORKSPACE_NAME: ${{ vars.IOS_WORKSPACE_NAME }}
          CURRENT_BRANCH: ${{ env.CURRENT_BRANCH }}
          CURRENT_TAG: ${{ env.CURRENT_TAG }}
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          APP_STORE_CONNECT_API_PRIVATE_KEY_PATH: ${{ env.APP_STORE_CONNECT_API_PRIVATE_KEY_PATH }}
        run: |
          # setup scheme name
          if [ $CURRENT_BRANCH == 'main' ] && echo "$CURRENT_TAG" | grep -Eq "^v[0-9]+\.[0-9]+\.[0-9]+$"; then
            SCHEME_NAME=$IOS_SCHEME_NAME_PRODUCTION
          else
            SCHEME_NAME=$IOS_SCHEME_NAME_DEV
          fi

          echo "Building $SCHEME_NAME scheme"

          # build archive
          xcodebuild -workspace "$IOS_WORKSPACE_NAME.xcworkspace" \
            -scheme "$SCHEME_NAME" \
            -archivePath $RUNNER_TEMP/$IOS_APP_NAME.xcarchive \
            -sdk iphoneos \
            -configuration Release \
            -allowProvisioningUpdates \
            -authenticationKeyIssuerID "$APP_STORE_CONNECT_API_ISSUER_ID" \
            -authenticationKeyID "$APP_STORE_CONNECT_API_KEY_ID" \
            -authenticationKeyPath "$APP_STORE_CONNECT_API_PRIVATE_KEY_PATH" \
            clean archive || exit 1

      - name: Export ipa
        env:
          IOS_APP_NAME: ${{ vars.IOS_APP_NAME }}
          EXPORT_OPTIONS_PLIST: ${{ secrets.EXPORT_OPTIONS_PLIST }}
          CURRENT_BRANCH: ${{ env.CURRENT_BRANCH }}
          CURRENT_TAG: ${{ env.CURRENT_TAG }}
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          APP_STORE_CONNECT_API_PRIVATE_KEY_PATH: ${{ env.APP_STORE_CONNECT_API_PRIVATE_KEY_PATH }}
        run: |
          # setup export options plist
          EXPORT_OPTS_PATH=$RUNNER_TEMP/ExportOptions.plist

          echo -n "$EXPORT_OPTIONS_PLIST" | base64 --decode -o $EXPORT_OPTS_PATH
          
          # export ipa
          xcodebuild -exportArchive \
            -archivePath $RUNNER_TEMP/$IOS_APP_NAME.xcarchive \
            -exportOptionsPlist $EXPORT_OPTS_PATH \
            -exportPath $RUNNER_TEMP/build \
            -allowProvisioningUpdates \
            -authenticationKeyIssuerID "$APP_STORE_CONNECT_API_ISSUER_ID" \
            -authenticationKeyID "$APP_STORE_CONNECT_API_KEY_ID" \
            -authenticationKeyPath "$APP_STORE_CONNECT_API_PRIVATE_KEY_PATH" \

      - name: Upload to TestFlight
        if: github.ref_type == 'tag'
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          CURRENT_BRANCH: ${{ env.CURRENT_BRANCH }}
          CURRENT_TAG: ${{ env.CURRENT_TAG }}
        run: |
          # check if branch name and tag name is what we want
          if [ $CURRENT_BRANCH == 'main' ] && echo "$CURRENT_TAG" | grep -Eq "^v[0-9]+\.[0-9]+\.[0-9]+$"; then
            OK_TO_UPLOAD=true
          elif [ $CURRENT_BRANCH == 'develop' ] && echo "$CURRENT_TAG" | grep -Eq "^dev[0-9]+\.[0-9]+\.[0-9]+$"; then
            OK_TO_UPLOAD=true
          else
            OK_TO_UPLOAD=false
          fi

          if ! $OK_TO_UPLOAD; then
            echo 'Tag name not fits rule'
            exit 1
          fi

          IPA_FILE_PATH=$(find $RUNNER_TEMP/build -name "*.ipa" -print0 | xargs -0 echo | head -n 1)
          echo "IPA file path: $IPA_FILE_PATH"

          xcrun altool --upload-app \
            -t ios \
            --apiKey "$APP_STORE_CONNECT_API_KEY_ID" \
            --apiIssuer "$APP_STORE_CONNECT_API_ISSUER_ID" \
            -f "$IPA_FILE_PATH" || exit 1

      - name: Clean up keychain and provisioning profile
        if: ${{ always() }}
        run: |
          # Clean up keychain
          security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
          # Clean Derived Data
          rm -rf ~/Library/Developer/Xcode/DerivedData/*

      - name: Restore original keychain
        if: always()  # This ensures it runs even if previous steps fail
        env:
          ORIGINAL_KEYCHAIN: ${{ env.ORIGINAL_KEYCHAIN }}
        run: |
          security default-keychain -s "$ORIGINAL_KEYCHAIN"
          security list-keychains -s "$ORIGINAL_KEYCHAIN"

Install the Apple certificate and provisioning profile

这步骤的目的就是把放在 GitHub secrets 中的东西做下列事情

  • secrets.BUILD_CERTIFICATE_BASE64 (开发者凭证内容) 变成实体档案 (p12)
  • secrets.BUILD_PROVISION_PROFILE_BASE64 (mobileprovision 档案内容) 变成实体档案
  • 使用 secrets.KEYCHAIN_PASSWORD 来建立一个叫 app-signing 的 keychain
  • 使用 secrets.P12_PASSWORD 把开发者凭证档案汇入到 app-signing keychain 中

Create App Store Connect API Key File

把 App Store Connect API 的 private key (secrets.APP_STORE_CONNECT_API_PRIVATE_KEY) 转化成实体档案 (p8) 并且放置于 <current_directory>/private_keys 的路径

此档案会在 xcbuild archive/exportArchive 使用到,并且于上传 Testflight 的 xcbuild altool 使用

altool 会自动寻找该档案,会自动寻找的路径其中一个地方就是 <current_directory>/private_keys,所以这边放置于此,另外档案名称也要注意,要与当初下载时的名称一样

详细可以放置位置及工具使用说明请参考下方 altool 使用指南 1.3 连结,或者当出现错误的时候,错误讯息的内容会跟你说可以摆在哪一些路径中

Build archive

顾名思义,就是 archive 档案,这边的 scheme 是用动态决定的、有的东西是拿 GitHub variable 决定的,但没有需要可以写死也可以,这边会这样写是方便如果要直接拿这份 YAML 去改的话,这边就不用修改,只要在需使用的 repo 上面新增对应的 variable 即可

Export ipa

先把放在 GitHub secrets 中的 secrets.EXPORT_OPTIONS_PLIST 变成实体档案,然后再去执行 export 指令

在 Build archive 及 Export ipa 步骤的重点

都指定下面四个参数

-allowProvisioningUpdates \
-authenticationKeyIssuerID "$APP_STORE_CONNECT_API_ISSUER_ID" \
-authenticationKeyID "$APP_STORE_CONNECT_API_KEY_ID" \
-authenticationKeyPath "$APP_STORE_CONNECT_API_PRIVATE_KEY_PATH" \

让 xcodebuild 可以与 server 沟通自动做 sign & 取得 mobile provision profile,加上这几个参数后,CI 机器上面的 XCode 就不需要登入

Upload to TestFlight

因为 xcbuild exportArchive 的时候只能指定输出的路径不能包含档名,所以不能确定最终的档名是什么,看起来是跟 archive 时候使用的 scheme name 一致,所以调整写法直接用指令去寻找 IPA 档案并上传

补充:其他 first party 上传 Test fligh 的方式

  1. iTMSTransporter (CLI)

    使用 CLI 上传 IPA 到 testflight 其实还有另外一个是 iTMSTransporter / xcrun iTMSTransporter

    根据官方文件 Transporter User Guide 3.3 中描述,在 xcode 14 之后就没有内建了需要另外下载

    但我测试目前使用的 XCode 16 下 xcrun iTMSTransporter 还是可以执行,并且如果直接下 xcrun iTMSTransporter 还可以更新版本

    iTMSTransporter 似乎是比 altool 似乎更早出现的工具,上传到 test flight 的时候虽然同 altool 一样会需要把 private key 放到指定的一些目录,但它有其他参数可以指定 JWT 的路径,这个 JWT 想当然有包含 private key (p8) 的内容,要组成 JWT 可以参考 Generating Tokens for API Requests

    意思就是可以自己先把 private key 转换成一个 App Store Connect API 所认得得 JWT 档案,就不一定要把 private key 摆放在特定位置,但 JWT 必须动态产生,因为 JWT 是需要指定期限会过期

    另外,iTMSTransporter 虽然跟 altool 一样可以寻找特定一些目录去找 private keys 档案 (p8) 来使用,但有遇到 2 个问题

    1. 不会找执行指令路径下的 <current_directory>/private_keys
    2. 用 GitHub Runner 执行的话也即便放到规範的 ~/private_keys 里面会找不到,但如果自己直接下指令是正常的

    所以要使用 iTMSTransporter 的话应该要使用 JWT 的方式比较合用

  2. XCode (GUI)

  3. Transporter App (GUI, Install from App Store)

Runner Keychain 的特别现象

手动执行完 workflow 后 Keychain 里面的 登入 会消失,这可能会导致后续无法正常 build code,自动执行的话似乎不会遇到这种情况

所以可以看到 step 里面有 保留 和 恢复 的步骤

TO-DO

  • xcrun altool --upload-app 已经被标示成 deprecated 需要找寻替代指令
    • 可能的替代方案
      • fast-lane
      • xcrun altool --upload-package
      • xcrun iTMSTransporter
      • 使用别人写好的 apple-actions

参考资料

  • 3 ways to install Xcode on macOS [2023]
  • How to build an iOS app archive via command line
  • How to build an iOS app with GitHub Actions [2023]
    • 此文件的根据文章
  • [Day:27] GitHub Actions 懒人部署-ios CI 基础打包
    • 可以参考写 build number 及有利用别人写好的几个 apple-actions
  • Xcode Provisioning Profile Automation for CI
  • 脚本打包所需ExportOptions.plist文件生成
  • 自动化流程完成 打包 IPA 到 上传 AppStore 之 iOS IPA签名
    • 里面有自己去下命令去 sign app 的,不确定为什么他需要自己去 sign,因为 archive 的命令应该就会 code sign 了
  • 一行命令解决 Codesign wants to access key “access” in your keychain
  • Distribute apps in Xcode with cloud signing

上传 Test Flight 相关

  • 上传建置版本
    • 官方文件,上传到 Test Fligh 用的指令範例是 xcrun altool --upload-app
  • altool 使用指南 1.3
  • Transporter User Guide 3.3
    • 现在的版本已经到 3.4.x 了
  • 利用 GitHub Actions 与 fastlane 将 iOS APP 发布到 Apple Store Connect 的 TestFlight
  • Deploy iOS App to TestFlight with GitHub Actions & Fastlane

关于作者: 网站小编

码农网专注IT技术教程资源分享平台,学习资源下载网站,58码农网包含计算机技术、网站程序源码下载、编程技术论坛、互联网资源下载等产品服务,提供原创、优质、完整内容的专业码农交流分享平台。

热门文章

5 点赞(415) 阅读(67)