美文网首页
CodePush 流程介绍及源码分析

CodePush 流程介绍及源码分析

作者: FingerStyle | 来源:发表于2022-01-09 14:41 被阅读0次

    一、业务流程

    1.正常更新流程

    1)默认情况下APP如果是第一次启动,则更新完成后立刻重启bridge,否则等到下次启动才重启bridge(可以设置强制更新,但重启会白屏)
    2)如果下载过程中因断网、APP退出而导致下载失败的,已下载的数据作废,需要重新下载
    3)下载过程中不能连代理,否则会下载失败(https证书校验通不过)
    4)可以分包下载,不过需要修改源码,更新逻辑比较复杂

    image.png

    1.1 检查更新:这是codepush的第一步,也是最重要的一步!!
    主要代码在JS这边,从RN包加载后,就开始调用 CodePushUtil 的init方法开始检查更新,然后调用RN自带的网路组件去请求codepush服务器。 客户端把本地RN包的版本号(label)、hash值和APP版本传给服务端, 检查是否有新的包可以下载。 如果有,则会返回对应的地址和其他相应字段,如果没有,则地址为空字符串

    返回数据(有新版本):

    {
        "updateInfo": {
            "downloadURL": "https://cstatic.xxx.net/code-push-download/lh9RADUHUusZEaLk9T1T5j6OnLV_",
            "downloadUrl": "https://cstatic.xxx.net/code-push-download/lh9RADUHUusZEaLk9T1T5j6OnLV_",
            "description": "",
            "isAvailable": true,
            "isDisabled": false,
            "isMandatory": false,
            "appVersion": "4.2.3",
            "targetBinaryRange": ">=3.5.9",
            "packageHash": "bfa5e47bf38cceeaee107d390dd9e004fc38ce54558b53ee4596b2fad0e838fe",
            "label": "v1315",
            "packageSize": 5806711,
            "updateAppVersion": false,
            "shouldRunBinaryVersion": false
        }
    }
    

    返回数据(无新版本):

    {
        "updateInfo": {
            "downloadUrl": "",
            "description": "",
            "isAvailable": false,
            "isDisabled": true,
            "isMandatory": false,
            "appVersion": "4.2.3",
            "targetBinaryRange": "",
            "packageHash": "",
            "label": "",
            "packageSize": 0,
            "updateAppVersion": false,
            "shouldRunBinaryVersion": false
        }
    }
    

    返回的结果会保存在本地对应版本的 的 app.json文件中,路径为 沙盒的文档目录(不会被系统自动清理) /CodePush/ 本地包的hash值/app.json。

    请求逻辑代码主要在CodePush.js的sync->syncInternal-> checkForUpdate方法内,实际请求的代码是放在 acquisition-sdk 的queryUpdateWithCurrentPackage 方法内。

    这里简单看一下syncInternal方法的实现,这个方法比较长,拆成三段来看:

    首先是初始化syncOptions和申明syncStatusChangeCallback,这个syncStatusChangeCallback是通过参数外部传入的,后面会多次调用

    /*
     * The syncInternal method provides a simple, one-line experience for
     * incorporating the check, download and installation of an update.
     *
     * It simply composes the existing API methods together and adds additional
     * support for respecting mandatory updates, ignoring previously failed
     * releases, and displaying a standard confirmation UI to the end-user
     * when an update is available.
     */
    async function syncInternal(options = {}, syncStatusChangeCallback, downloadProgressCallback, downloadFailedCallback, handleBinaryVersionMismatchCallback, syncCallbackOptions = {}) {
    let resolvedInstallMode;
      const syncOptions = {
        deploymentKey: null,
        ignoreFailedUpdates: true,
        rollbackRetryOptions: null,
        installMode: CodePush.InstallMode.ON_NEXT_RESTART,
        mandatoryInstallMode: CodePush.InstallMode.IMMEDIATE,
        minimumBackgroundDuration: 0,
        updateDialog: null,
        ...options
      };
    
    
      const { checkForUpdateOptions = {}, downloadOptions = {}, oneSelfAchieveOptions = {} } = syncCallbackOptions
    
      syncStatusChangeCallback = typeof syncStatusChangeCallback === "function"
        ? syncStatusChangeCallback
        : (syncStatus) => {
          switch (syncStatus) {
            case CodePush.SyncStatus.CHECKING_FOR_UPDATE:
              log("Checking for update.");
              break;
            case CodePush.SyncStatus.AWAITING_USER_ACTION:
              log("Awaiting user action.");
              break;
            case CodePush.SyncStatus.DOWNLOADING_PACKAGE:
              log("Downloading package.");
              break;
            case CodePush.SyncStatus.INSTALLING_UPDATE:
              log("Installing update.");
              break;
            case CodePush.SyncStatus.UP_TO_DATE:
              log("App is up to date.");
              break;
            case CodePush.SyncStatus.UPDATE_IGNORED:
              log("User cancelled the update.");
              break;
            case CodePush.SyncStatus.UPDATE_INSTALLED:
              if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESTART) {
                log("Update is installed and will be run on the next app restart.");
              } else if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESUME) {
                if (syncOptions.minimumBackgroundDuration > 0) {
                  log(`Update is installed and will be run after the app has been in the background for at least ${syncOptions.minimumBackgroundDuration} seconds.`);
                } else {
                  log("Update is installed and will be run when the app next resumes.");
                }
              }
              break;
            case CodePush.SyncStatus.UNKNOWN_ERROR:
              log("An unknown error occurred.");
              break;
          }
        };
    .......
    }
    

    第二部分是从try 开始一直到doDownloadAndInstall这个方法的定义结束,这里先是把syncStatus改为CHECKING_FOR_UPDATE,调用checkForUpdate。

    然后定义了doDownloadAndInstall,这里面是下载和安装的实际操作,包括调用了 remotePackage.download 下载RN包,调用localPackage.install进行安装,

    ......
    try {
        await CodePush.notifyApplicationReady();
    
    
        syncStatusChangeCallback(CodePush.SyncStatus.CHECKING_FOR_UPDATE);
        const remotePackage = await checkForUpdate(syncOptions.deploymentKey, handleBinaryVersionMismatchCallback, checkForUpdateOptions);
    
        const doDownloadAndInstall = async () => {
          syncStatusChangeCallback(CodePush.SyncStatus.DOWNLOADING_PACKAGE);
    
          let localPackage
          downloadOptions.downloadStartHandler && downloadOptions.downloadStartHandler()
          let isSuccess = true
          try {
            localPackage = await remotePackage.download(downloadProgressCallback, downloadFailedCallback);
            // 连了代理会下载不到
            // if (!localPackage) {
            //   isSuccess = false
            // }
          } catch (e) {
            isSuccess = false
          }
          downloadOptions.downloadEndHandler && downloadOptions.downloadEndHandler(isSuccess);
    
    
          // Determine the correct install mode based on whether the update is mandatory or not.
          resolvedInstallMode = localPackage.isMandatory ? syncOptions.mandatoryInstallMode : syncOptions.installMode;
    
          syncStatusChangeCallback(CodePush.SyncStatus.INSTALLING_UPDATE);
          await localPackage.install(resolvedInstallMode, syncOptions.minimumBackgroundDuration, syncOptions.isOneSelfAchieve, () => {
            syncStatusChangeCallback(CodePush.SyncStatus.UPDATE_INSTALLED);
          }, () => {
            //后加  下载完成 还未重新加载
            oneSelfAchieveOptions.oneSelfAchieveCallback && oneSelfAchieveOptions.oneSelfAchieveCallback()
          });
    
          return CodePush.SyncStatus.UPDATE_INSTALLED;
        };
    ......
    

    第三段是从 updateShouldBeIgnored 一直到方法结束,这里根据 checkForUpdate获取到的remotePackage和 shouldUpdateBeIgnored的结果分为三种情况:

    一是没有远程包或者之前这个包回滚过,这时候不会更新,而是把状态改为UPDATE_INSTALLED或者UP_TO_DATE;

    二是需要用户确认更新,这时候会有弹窗出来,当用户点击之后再更新(我们没有用到,可以忽略)

    三是默认情况,调用doDownloadAndInstall更新

    ......
        const updateShouldBeIgnored = await shouldUpdateBeIgnored(remotePackage, syncOptions);
    
        if (!remotePackage || updateShouldBeIgnored) {
          if (updateShouldBeIgnored) {
            log("An update is available, but it is being ignored due to having been previously rolled back.");
          }
    
          const currentPackage = await CodePush.getCurrentPackage();
          if (currentPackage && currentPackage.isPending) {
            syncStatusChangeCallback(CodePush.SyncStatus.UPDATE_INSTALLED);
            return CodePush.SyncStatus.UPDATE_INSTALLED;
          } else {
            syncStatusChangeCallback(CodePush.SyncStatus.UP_TO_DATE);
            return CodePush.SyncStatus.UP_TO_DATE;
          }
        } else if (syncOptions.updateDialog) {
          // updateDialog supports any truthy value (e.g. true, "goo", 12),
          // but we should treat a non-object value as just the default dialog
          if (typeof syncOptions.updateDialog !== "object") {
            syncOptions.updateDialog = CodePush.DEFAULT_UPDATE_DIALOG;
          } else {
            syncOptions.updateDialog = { ...CodePush.DEFAULT_UPDATE_DIALOG, ...syncOptions.updateDialog };
          }
    
          return await new Promise((resolve, reject) => {
            let message = null;
            let installButtonText = null;
    
            const dialogButtons = [];
    
            if (remotePackage.isMandatory) {
              message = syncOptions.updateDialog.mandatoryUpdateMessage;
              installButtonText = syncOptions.updateDialog.mandatoryContinueButtonLabel;
            } else {
              message = syncOptions.updateDialog.optionalUpdateMessage;
              installButtonText = syncOptions.updateDialog.optionalInstallButtonLabel;
              // Since this is an optional update, add a button
              // to allow the end-user to ignore it
              dialogButtons.push({
                text: syncOptions.updateDialog.optionalIgnoreButtonLabel,
                onPress: () => {
                  syncStatusChangeCallback(CodePush.SyncStatus.UPDATE_IGNORED);
                  resolve(CodePush.SyncStatus.UPDATE_IGNORED);
                }
              });
            }
    
            // Since the install button should be placed to the
            // right of any other button, add it last
            dialogButtons.push({
              text: installButtonText,
              onPress: () => {
                doDownloadAndInstall()
                  .then(resolve, reject);
              }
            })
    
            // If the update has a description, and the developer
            // explicitly chose to display it, then set that as the message
            if (syncOptions.updateDialog.appendReleaseDescription && remotePackage.description) {
              message += `${syncOptions.updateDialog.descriptionPrefix} ${remotePackage.description}`;
            }
    
            syncStatusChangeCallback(CodePush.SyncStatus.AWAITING_USER_ACTION);
            Alert.alert(syncOptions.updateDialog.title, message, dialogButtons);
          });
        } else {
          return await doDownloadAndInstall();
        }
      } catch (error) {
        syncStatusChangeCallback(CodePush.SyncStatus.UNKNOWN_ERROR);
        log(error.message);
        throw error;
      }
    

    1.2 下载bundle包
    根据步骤(1)返回的downloadUrl进行下载,这个过程主要是在原生这边做的,JS只是触发原生的downloadUpdate方法,并监听下载完成的回调,在回调里面触发RN包的安装和状态上报。
    代码在package-mixins.js 的 remote.download方法内

    1.3 安装bundle包
    把步骤(2)得到的bundle包进行解压并拷贝到指定目录。这个过程也是以原生为主,JS这边触发了原生的installUpdate方法,并在这个方法执行完成后调用restartApp重启 bridge。
    代码在package-mixins.js 的 local.install方法内

    1.4 上报下载状态
    上报下载成功的状态,主要是用于统计,失败或者无更新时不会上报。这个接口会修改到服务端的数据库,是引发服务器性能瓶颈的一个点。
    接口地址: https://codepush.xx.net/reportStatus/download

    请求参数

    {
    "clientUniqueId": "A25E48C6-1766-431B-B979-6F1CD2607579",
    "deploymentKey": "57w3cawU4Q93X95zdP84FVY3YIxQ4ksvOXqog",
    "label": "v1323",
    }
    

    具体代码在 acquisition-sdk 的 reportStatusDownload 方法内

    1.5 上报安装状态
    上报安装成功还是失败的状态,主要是用于统计,如果是安装失败,还把这个包标记为不可更新,下次RN包出问题时会根据这个标记来决定是否回滚。这个接口会修改到服务端的数据库,是引发服务器性能瓶颈的一个点。
    接口地址: https://codepush.xx.net/reportStatus/deploy

    请求参数

    {
    "appVersion": "4.2.3",
    "deploymentKey": "57w3cawU4Q93X95zdP84FVY3YIxQ4ksvOXqog",
    "clientUniqueId": "A25E48C6-1766-431B-B979-6F1CD2607579",
    "label": "v1323",
    "status": "DeploymentSucceeded",
    "previousLabelOrAppVersion": "v1315",
    "previousDeploymentKey": "57w3cawU4Q93X95zdP84FVY3YIxQ4ksvOXqog",
    }
    

    主要代码在Codepush.js 的 getNewStatusReport 方法(从原生获取上报状态)和 acquisition-sdk 的 reportStatusDeploy 方法内(发请求), 设置回滚标记的写在原生的 setLatestRollbackInfo 方法内
    下面简单分析下getNewStatusReport在iOS端的实现:

    /*
    * This method is checks if a new status update exists (new version was installed,
    * or an update failed) and return its details (version label, status).
    */
    RCT_EXPORT_METHOD(getNewStatusReport:(RCTPromiseResolveBlock)resolve
    rejecter:(RCTPromiseRejectBlock)reject)
    {
       if (needToReportRollback) { //如果当前包需要上报回滚(更新后发生严重错误导致无法运.行),则通过NSUserDefaults获取到最近的更新失败的包信息,返回给JS
          needToReportRollback = NO;
          NSUserDefaults *preferences = [NSUserDefaults standardUserDefaults];
          NSMutableArray *failedUpdates = [preferences objectForKey:FailedUpdatesKey];
          if (failedUpdates) {
             NSDictionary *lastFailedPackage = [failedUpdates lastObject];
             if (lastFailedPackage) {
                resolve([CodePushTelemetryManager getRollbackReport:lastFailedPackage]);
                return;
             }
          }
       } else if (_isFirstRunAfterUpdate) {  //如果是更新后第一次运行(即更新成功),则返回这个包的信息给JS
          NSError *error;
          NSDictionary *currentPackage = [CodePushPackage getCurrentPackage:&error];
          if (!error && currentPackage) {
          resolve([CodePushTelemetryManager getUpdateReport:currentPackage]);
          return;
        }
      } else if (isRunningBinaryVersion) { //如果运行的是本地包,则把本地包信息给JS (APP版本之类的信息)
        NSString *appVersion = [[CodePushConfig current] appVersion];
        resolve([CodePushTelemetryManager getBinaryUpdateReport:appVersion]);
        return;
      } else { //如果有重新上报的标记,则把重新上报的状态给JS(因为超时或其他原因导致需重新上报)
        NSDictionary *retryStatusReport = [CodePushTelemetryManager getRetryStatusReport];
        if (retryStatusReport) {
          resolve(retryStatusReport);
          return;
        }
      }
    //默认什么都不返回
      resolve(nil);
    }
    

    这里回滚的情况比较复杂,下面异常更新流程会详细展开

    2. 异常更新流程

    1)本地有两到三个包,一个跟随APP发布的默认包,另外一到两个通过codepush下载的最新包。如果最新包出错,则回滚到上一个包,如果还是出错,则加载默认包。
    2)本地包加载出错时,默认会崩溃,可以加拦截让其不崩溃,但会白屏。
    3)一个用户出现回滚后会把出错的信息上报服务端,服务端标记这个RN包版本有问题,后续这个用户再也不会下载这个包,除非手动修改服务端标记。

    image.png

    2.1 本地有两到三个包,一个跟随APP发布的默认包,另外一到两个是通过codepush下载的RN包,其中一个是最近更新的包,另一个是上一次更新的包(如果第一次更新,则没有这个包)。

    如果最新包出错,则回滚到上一个包,如果上一个包不存在或者还是出错, 到回滚到默认包。

    2.2 本地包出错时,默认会崩溃,我们加了拦截,让其不崩溃,但会白屏,并自动重启bridge。

    2.3 一个用户出现回滚后会把出错的信息上报服务端,服务端标记这个RN包版本有问题,后续这个用户再也不会下载这个包,除非手动修改服务端标记。

    针对回滚的流程, 我们来看下具体的代码,首先是needToReportRollback这个bool变量,前面提到在getNewStatusReport 这个方法内会用到这个标记,那这个标记什么赋值的呢?

    先看下initializeUpdateAfterRestart 这个方法,这是是在CodePush启动之后就立即调用,在updateIsLoading为true的时候将这个标记改为YES(也就是true)。

    这个updateIsLoading变量在codepush的一个生命周期内只会修改一次,默认是true,当安装成功后会改为false(见installUpdate方法)。

    在标记需要上报回滚之后,就调用rollbackPackage进行本地回滚了

    /*
     * This method is used when the app is started to either
     * initialize a pending update or rollback a faulty update
     * to the previous version.
     */
    - (void)initializeUpdateAfterRestart
    {
    #ifdef DEBUG
        [self clearDebugUpdates];
    #endif
        self.paused = YES;
        NSUserDefaults *preferences = [NSUserDefaults standardUserDefaults];
        NSDictionary *pendingUpdate = [preferences objectForKey:PendingUpdateKey];
        if (pendingUpdate) {
            _isFirstRunAfterUpdate = YES;//标记是更新后第一次运行
            BOOL updateIsLoading = [pendingUpdate[PendingUpdateIsLoadingKey] boolValue];
            if (updateIsLoading) {
                NSDictionary *installUpdate = [preferences objectForKey:PendingInstallKey];
                if (installUpdate) {
                    BOOL updateIsLoading = [installUpdate[PendingUpdateIsInstallKey] boolValue];
                    if (!updateIsLoading) {
                        // Pending update was initialized, but notifyApplicationReady was not called.
                        // Therefore, deduce that it is a broken update and rollback.
                        CPLog(@"Update did not finish loading the last time, rolling back to a previous version.");
                        needToReportRollback = YES;//标记需要上报回滚
                        [self rollbackPackage];//进行本地回滚
                    }
                }else{
                    [CodePush removePendingUpdate];
                    [CodePush removePendingInstall];
                }
            } else {
                // Mark that we tried to initialize the new update, so that if it crashes,
                // we will know that we need to rollback when the app next starts.
               //默认updateIsLoading设置为true
                [self savePendingUpdate:pendingUpdate[PendingUpdateHashKey]
                              isLoading:YES];
            }
        }
    }
    
    /*
     * This method is the native side of the LocalPackage.install method.
     */
    RCT_EXPORT_METHOD(installUpdate:(NSDictionary*)updatePackage
                        installMode:(CodePushInstallMode)installMode
          minimumBackgroundDuration:(int)minimumBackgroundDuration
                           resolver:(RCTPromiseResolveBlock)resolve
                           rejecter:(RCTPromiseRejectBlock)reject)
    {
        [[NSNotificationCenter defaultCenter] postNotificationName:CodePushWillInstallUpdateNotification object:nil userInfo:updatePackage];
        NSError *error;
        //这一步进行安装,如果出错会通过error参数把错误返回出来
        [CodePushPackage installPackage:updatePackage
                    removePendingUpdate:[[self class] isPendingUpdate:nil]
                                  error:&error];
        [[NSNotificationCenter defaultCenter] postNotificationName:CodePushDidInstallUpdateNotification object:nil userInfo:updatePackage];
       //如果安装出错,则返回异常,否则修改updateIsLoading的状态和installMode,并监听APP重新返回前台的通知(收到通知后重启bundle)
        if (error) {
            [self saveInstallFailUpdate:updatePackage[PackageHashKey] isInstall:NO];
            reject([NSString stringWithFormat: @"%lu", (long)error.code], error.localizedDescription, error);
        } else {
            //修改updateIsLoading为false. 
            [self savePendingUpdate:updatePackage[PackageHashKey]
                          isLoading:NO];
    
            _installMode = installMode;
            if (_installMode == CodePushInstallModeOnNextResume || _installMode == CodePushInstallModeOnNextSuspend) {
                _minimumBackgroundDuration = minimumBackgroundDuration;
    
                if (!_hasResumeListener) {
                    // Ensure we do not add the listener twice.
                    // Register for app resume notifications so that we
                    // can check for pending updates which support "restart on resume"
                    [[NSNotificationCenter defaultCenter] addObserver:self
                                                             selector:@selector(applicationDidBecomeActive)
                                                                 name:UIApplicationDidBecomeActiveNotification
                                                               object:RCTSharedApplication()];
                                                               
                    [[NSNotificationCenter defaultCenter] addObserver:self
                                                             selector:@selector(applicationWillEnterForeground)
                                                                 name:UIApplicationWillEnterForegroundNotification
                                                               object:RCTSharedApplication()];
    
                    [[NSNotificationCenter defaultCenter] addObserver:self
                                                             selector:@selector(applicationWillResignActive)
                                                                 name:UIApplicationWillResignActiveNotification
                                                               object:RCTSharedApplication()];
    
                    _hasResumeListener = YES;
                }
            }
    
            // Signal to JS that the update has been applied.
            resolve(nil);
        }
    }
    

    回滚的具体实现 , 注意一下这里 failedPackage 的信息被写到了本地,在上报安装状态时会通过 getNewStatusReport拿到,同步给服务端,以确保有问题的包不会再次下发。

    CodePush.m

    /*
     * This method is used when an update has failed installation
     * and the app needs to be rolled back to the previous bundle.
     * This method is automatically called when the rollback timer
     * expires without the app indicating whether the update succeeded,
     * and therefore, it shouldn't be called directly.
     */
    - (void)rollbackPackage
    {
        NSError *error;
        NSDictionary *failedPackage = [CodePushPackage getCurrentPackage:&error];
        if (!failedPackage) {
            if (error) {
                CPLog(@"Error getting current update metadata during rollback: %@", error);
            } else {
                CPLog(@"Attempted to perform a rollback when there is no current update");
            }
        } else {
            // Write the current package's metadata to the "failed list"
            //本地记录更新失败的包,以便后续通过状态上报接口同步给服务端
            [self saveFailedUpdate:failedPackage];
        }
    
        // Rollback to the previous version and de-register the new update
        [CodePushPackage rollbackPackage]; //清除本地bundle 及所在文件夹(包括app.json),并把上一个包设置为当前包
        [CodePush removePendingUpdate];
        [CodePush removePendingInstall];
        [self loadBundle]; //重启bundle
    }
    
    /*
     * This method updates the React Native bridge's bundle URL
     * to point at the latest CodePush update, and then restarts
     * the bridge. This isn't meant to be called directly.
     */
    - (void)loadBundle
    {
        // This needs to be async dispatched because the bridge is not set on init
        // when the app first starts, therefore rollbacks will not take effect.
        dispatch_async(dispatch_get_main_queue(), ^{
            // If the current bundle URL is using http(s), then assume the dev
            // is debugging and therefore, shouldn't be redirected to a local
            // file (since Chrome wouldn't support it). Otherwise, update
            // the current bundle URL to point at the latest update
            if ([CodePush isUsingTestConfiguration] || ![super.bridge.bundleURL.scheme hasPrefix:@"http"]) {
                [super.bridge setValue:[CodePush bundleURL] forKey:@"bundleURL"];
            }
            //重启bridge,重新运行bundle
            [super.bridge reload];
        });
    }
    

    CodePushPackage.m

    + (void)rollbackPackage
    {
        NSError *error;
        NSMutableDictionary *info = [self getCurrentPackageInfo:&error];
        if (!info) {
            CPLog(@"Error getting current package info: %@", error);
            return;
        }
        
        NSString *currentPackageFolderPath = [self getCurrentPackageFolderPath:&error];
        if (!currentPackageFolderPath) {
            CPLog(@"Error getting current package folder path: %@", error);
            return;
        }
        //清除本地bundle 及所在文件夹
        NSError *deleteError;
        BOOL result = [[NSFileManager defaultManager] removeItemAtPath:currentPackageFolderPath
                                                   error:&deleteError];
    
        if (!result) {
            CPLog(@"Error deleting current package contents at %@ error %@", currentPackageFolderPath, deleteError);
        }
        //把上一个包设置为当前包,清除上一个包的信息
        [info setValue:info[@"previousPackage"] forKey:@"currentPackage"];
        [info removeObjectForKey:@"previousPackage"];
        
        [self updateCurrentPackageInfo:info error:&error];
        [[CodePushReport shared] reportRollback];
    }
    

    相关文章

      网友评论

          本文标题:CodePush 流程介绍及源码分析

          本文链接:https://www.haomeiwen.com/subject/zzyjoltx.html