Kiwix PWA 如何允许用户存储来自互联网的 GB 级数据以供离线使用

Geoffrey Kantaris
Geoffrey Kantaris
Stéphane Coillet-Matillon
Stéphane Coillet-Matillon

人们聚在一台笔记本电脑旁,站在一张简单的桌子上,左边有一把塑料椅子。背景看起来像是发展中国家/地区的一所学校。

本案例研究探讨了非营利组织 Kiwix 如何使用渐进式 Web 应用技术和 File System Access API 来允许用户下载和存储大型互联网归档以供离线使用。了解处理源私有文件系统 (OPFS) 的代码的技术实现。OPFS 是 Kiwix PWA 中的一项新浏览器功能,可增强文件管理,无需权限提示即可改进对归档的访问。本文讨论了这一新文件系统面临的挑战,并重点介绍了这一新文件系统的未来发展。

Kiwix 简介

根据国际电信联盟的数据,在网络诞生 30 多年后,世界上三分之一的人口仍在等待稳定的互联网访问。事情到这里就结束了?当然不是。Kiwix 是瑞士的一家公益组织,他们开发了一个由开源应用和内容组成的生态系统,该生态系统旨在为互联网访问权限受限或无法访问的用户提供知识。他们的想法是,如果无法轻松访问互联网,则有人可以在有连接的位置和时间为您下载关键资源,并将这些资源存储在本地以供日后离线使用。许多重要的网站(例如维基百科、古登堡项目、Stack Exchange 甚至 TED 讲座)现在可以转换为高度压缩的归档文件(称为 ZIM 文件),并可通过 Kiwix 浏览器即时阅读。

ZIM 归档采用高效的 Zstandard (ZSTD) 压缩(旧版本使用 XZ),主要用于存储 HTML、JavaScript 和 CSS,而图片通常会转换为压缩的 WebP 格式。每个 ZIM 还包含一个网址和一个标题索引。压缩是关键所在,因为在转换为 ZIM 格式后,整个维基百科(640 万篇文章及图片)被压缩至 97 GB。在您意识到中端 Android 手机现已可以支持所有人类知识的总和之前,这听起来似乎很久。此外,还提供许多较小的资源,包括主题版本的维基百科,例如数学、医学等。

Kiwix 提供了一系列原生应用,这些应用面向桌面设备 (Windows/Linux/macOS) 和移动设备 (iOS/Android) 应用。不过,本案例研究将重点介绍渐进式 Web 应用 (PWA),该应用旨在成为适用于任何搭载现代浏览器设备的通用且简单的解决方案。

我们将探讨在开发需要完全离线快速访问大型内容归档的通用 Web 应用以及一些现代 JavaScript API,尤其是 File System Access API源私有文件系统,它们能够为这些挑战提供创新和激动人心的解决方案。

想要离线使用的 Web 应用?

Kiwix 用户是不拘一格的群体,有着许多不同的需求,而 Kiwix 几乎无法控制或无法控制他们用于访问其内容的设备和操作系统。其中一些设备可能会运行缓慢或已过时,尤其是在世界上的低收入地区。虽然 Kiwix 力求涵盖尽可能多的用例,但该组织还意识到,他们可以在任何设备上使用最通用的软件(网络浏览器)来覆盖更多用户。因此,受阿特伍德定律的启发,任何可使用 JavaScript 编写的应用最终都会用 JavaScript 编写。大约 10 年前,有些 Kiwix 开发者已经着手准备将 Kiwix 软件从 C++ 移植到 JavaScript。

此端口的第一个版本 Kiwix HTML5 适用于现已失效的 Firefox 操作系统和浏览器扩展程序。它的核心是一个使用 Emscripten 编译器编译为 ASM.js 中间 JavaScript 语言以及后来的 Wasm(即 WebAssembly)的 C++ 解压缩引擎(XZ 和 ZSTD)。后来改名为 Kiwix JS浏览器扩展程序仍在积极开发中。

Kiwix JS 离线浏览器

输入渐进式 Web 应用 (PWA)。发挥了这项技术的潜力,Kiwix 开发者构建了专门的 PWA 版本 Kiwix JS,并开始添加操作系统集成,使应用能够提供类似原生代码的功能,尤其是在离线使用、安装、文件处理和文件系统访问方面。

离线优先 PWA 极为轻量,因此非常适合移动互联网断断续续或费用高昂的情境。其背后的技术是 Service Worker API 和相关的 Cache API,可供所有基于 Kiwix JS 的应用使用。这些 API 允许应用充当服务器,从正在查看的主文档或文章拦截提取请求,并将其重定向到 (JS) 后端,以便从 ZIM 归档提取和构建响应。

存储,无处不在

由于 ZIM 归档文件非常大,因此对它的存储和访问(尤其是在移动设备上)可能是 Kiwix 开发者最头疼的问题。许多 Kiwix 最终用户在连接到互联网的情况下在应用内下载内容,以供日后离线使用。其他用户使用 Torrent 文件在 PC 上下载,然后转移到移动设备或平板电脑,还有一些用户会在移动互联网不齐全或费用高昂的地区通过 U 盘或便携式硬盘交换内容。所有这些从用户可访问的任意位置访问内容的方式都需要 Kiwix JS 和 Kiwix PWA 的支持。

最初使得 Kiwix JS 能够读取数百 GB 的海量归档(我们的 ZIM 归档之一为 166 GB!),即使在低内存设备上也是如此,在于 File API。此 API 在所有浏览器(甚至非常旧的浏览器)中受到普遍支持,因此可以作为通用回退,以便在较新的 API 不受支持时发挥作用。在 Kiwix 中,这就像在 HTML 中定义 input 元素一样简单:

<input
  type="file"
  accept="application/octet-stream,.zim,.zimaa,.zimab,.zimac, ..."
  value="Select folder with ZIM files"
  id="archiveFilesLegacy"
  multiple
/>

选择后,输入元素将包含 File 对象,这些对象本质上是引用存储空间中的底层数据的元数据。从技术上讲,Kiwix 的面向对象的后端采用纯客户端 JavaScript 编写,可根据需要读取小型归档内容。如果需要解压缩这些 Slice,后端会将它们传递给 Wasm 解压缩器,从而根据请求获取更多 Slice,直到解压缩完整的 blob(通常是文章或资产)。这意味着,系统永远不必将大型归档内容完全读取到内存中。

就通用性而言,File API 有一个缺点,使得 Kiwix JS 应用与原生应用相比显得比较笨拙和过时:它要求用户在每次应用启动时使用文件选择器选择归档文件,或将文件拖放到应用中,因为使用此 API 时,无法在一个会话之间保留访问权限。

为了缓解这种糟糕的用户体验,与许多开发者一样,Kiwix JS 开发者最初选择了 Electron 路线。ElectronJS 是一个出色的框架,它提供强大的功能,包括使用 Node API 对文件系统的完整访问权限。但是,它也存在一些众所周知的缺点:

  • 它只能在桌面操作系统上运行。
  • 大小和繁重 (70MB-100MB)。

由于每个应用都包含 Chromium 的完整副本,因此 Electron 应用的大小远胜于最小化和捆绑的 PWA 的仅 5.1 MB

那么,Kiwix 有没有办法改善 PWA 用户的状况?

使用 File System Access API

大约在 2019 年,Kiwix 发现了一个新兴 API,它在 Chrome 78 中进行源试用,后来名为 Native File System API。它承诺能够获取文件或文件夹的文件句柄,并将其存储在 IndexedDB 数据库中。至关重要的是,此句柄在应用会话之间保持不变,因此重新启动应用时,用户不必再次选择文件或文件夹(但他们必须回答快速权限提示)。在正式版发布时,它已被重命名为 File System Access API,并且核心部分已被 WHATWG 标准化为 File System API (FSA)。

那么,API 的文件系统访问部分是如何运作的呢?需要注意的几点重要事项:

  • 它是一个异步 API(Web Worker 中的特殊函数除外)。
  • 文件或目录选择器必须通过捕获用户手势(点击或点按界面元素)以编程方式启动。
  • 要让用户再次授权(在新会话中)访问之前选择的文件,还需要执行用户手势。事实上,如果权限提示不是由用户手势启动,那么浏览器将拒绝显示权限提示。

代码相对简单,除了必须使用复杂的 IndexedDB API 存储文件和目录句柄之外。好消息是,有几个库可以代您完成许多繁杂的工作,比如 browser-fs-access。在 Kiwix JS 中,我们决定直接使用记录详尽的 API。

打开文件和目录选择器

打开文件选择器的操作如下所示(此处使用的是 Promise,但如果您更喜欢 async/await 糖,请参阅 Chrome for Developers 教程):

return window
  .showOpenFilePicker({ multiple: false })
  .then(function (fileHandles) {
    return processFileHandle(fileHandles[0]);
  })
  .catch(function (err) {
    // This is normal if app is launching
    console.warn(
      'User cancelled, or cannot access fs without user gesture',
      err,
    );
  });

请注意,为简单起见,此代码仅处理选择的第一个文件(并禁止选择多个文件)。如果您想使用 { multiple: true } 选择多个文件,只需将处理每个句柄的所有 Promise 封装在 Promise.all().then(...) 语句中,例如:

let promisesForFiles = fileHandles.map(function (fileHandle) {
    return processFileHandle(fileHandle);
});
return Promise.all(promisesForFiles).then(function (arrayOfFiles) {
    // Do something with the files array
    console.log(arrayOfFiles);
}).catch(function (err) {
    // Handle any errors that occurred during processing
    console.error('Error processing file handles!', err);
)};

但是,可以让用户选择包含这些文件的目录(而不是其中的单个文件),这样会更好,因为 Kiwix 用户倾向于将所有 ZIM 文件整理在同一目录中。启动目录选择器的代码与上述代码几乎相同,只不过您使用的是 window.showDirectoryPicker.then(function (dirHandle) { … });

处理文件或目录句柄

有了句柄后,您需要对其进行处理,因此函数 processFileHandle 可能如下所示:

function processFileHandle(fileHandle) {
  // Serialize fileHandle to indexedDB
  serializeFSHandletoIdxDB('pickedFSHandle', fileHandle, function (val) {
    console.debug('IndexedDB responded with ' + val);
  });
  return fileHandle.getFile().then(function (file) {
    // Do something with the file
    return file;
  });
}

请注意,您必须提供用于存储文件句柄的函数,除非使用抽象库,否则没有便捷的方法可以实现这一点。Kiwix 对此的实现可以在文件 cache.js 中看到,但如果它仅用于存储和检索文件或文件夹句柄,则可以大幅简化该实现。

处理目录稍微复杂一些,因为您必须使用异步 entries.next() 遍历所选目录中的条目,以查找所需的文件或文件类型。有多种方法可以实现这一目的,以下是 Kiwix PWA 中使用的代码概述:

let iterableEntryList = dirHandle.entries();
return iterateAsyncDirEntries(iterableEntryList, []).then(function (entryList) {
  // Do something with the entry list
  return entryList;
});

/**
 * Iterates FileSystemDirectoryHandle iterator and adds entries to an array
 * @param {Iterator} entries An asynchronous iterator of entries
 * @param {Array} archives An array to which to add the entries (may be empty)
 * @return {Promise<Array>} A Promise for an array of entries in the directory
 */
function iterateAsyncDirEntries(entries, archives) {
  return entries
    .next()
    .then(function (result) {
      if (!result.done) {
        let entry = result.value[1];
        // Filter for the files you want
        if (/\.zim(\w\w)?$/i.test(entry.name)) {
          archives.push(entry);
        }
        return iterateAsyncDirEntryArray(entries, archives);
      } else {
        // We've processed all the entries
        if (!archives.length) {
          console.warn('No archives found in the picked directory!');
        }
        return archives;
      }
    })
    .catch(function (err) {
      console.error('There was an error processing the directory!', err);
    });
}

请注意,对于 entryList 中的每个条目,您之后需要使用 entry.getFile().then(function (file) { … }) 获取该文件,或者在 async function 中使用 const file = await entry.getFile() 获取等效文件。

我们还能更进一步吗?

要求用户在应用后续启动时通过用户手势发起授予权限这一要求会给文件和文件夹的打开(重新打开)带来些许阻力,但这种方式仍然比强制重新选择文件要流畅得多。Chromium 开发者目前正在最终确定代码,以便为已安装的 PWA 授予永久权限。这是许多 PWA 开发者一直呼吁和期待的功能。

但如果我们不用等待,该怎么办?Kiwix 开发者最近发现,通过使用 File Access API 的一项精彩新功能,可以立即消除所有权限提示。此功能受 Chromium 和 Firefox 浏览器支持(Safari 部分支持,但仍然缺少 FileSystemWritableFileStream)。这项新功能是源私有文件系统

完全原生:源私有文件系统

源私有文件系统 (OPFS) 仍是 Kiwix PWA 中的一项实验性功能,但该团队非常期待鼓励用户试用该功能,因为它在很大程度上弥合了原生应用和 Web 应用之间的差距。它的主要优势如下:

  • 无需权限提示即可访问 OPFS 中的归档,即使在启动时也是如此。用户可以从上次会话中断的地方继续阅读文章和浏览归档内容,毫无阻碍。
  • 它可以高度优化地访问存储在其中的文件:在 Android 上,速度提高了 5 到 10 倍。

在 Android 中使用 File API 访问标准文件会非常缓慢,尤其是如果大型归档文件存储在 microSD 卡上而不是设备存储空间中(Kiwix 用户经常会这样访问),更是如此。这一新 API 带来的所有变化都是前所未有的。 虽然大多数用户将无法在 OPFS 中存储 97 GB 的文件(这会占用设备存储空间,而非 microSD 卡存储空间),但它非常适合存储中小型归档文件。您想要 WikiProject Medicine 中的最完整的医学百科全书吗?没问题,它可以在 1.7 GB 的容量下轻松放入 OPFS 中!(提示:在应用内库中查找 othermdwiki_en_all_maxi。)

OPFS 的工作原理

OPFS 是浏览器提供的文件系统,针对每个源是单独的,可以视为类似于 Android 上的应用分区存储。文件可以从用户可见的文件系统导入到 OPFS 中,也可以直接下载到 OPFS 中(API 还允许在 OPFS 中创建文件)。进入 OPFS 后,它们会与设备的其余部分隔离开来。在基于 Chromium 的桌面设备浏览器中,还可以将文件从 OPFS 导出回用户可见的文件系统。

如需使用 OPFS,第一步是使用 navigator.storage.getDirectory() 请求对其的访问权限(同样,如果您希望查看使用 await 的代码,请参阅源私有文件系统):

return navigator.storage
  .getDirectory()
  .then(function (handle) {
    return processDirHandle(handle);
  })
  .catch(function (err) {
    console.warn('Unable to get the OPFS directory entry', err);
  });

由此获取的句柄与从前面提到的 window.showDirectoryPicker() 获取的 FileSystemDirectoryHandle 类型完全相同,这意味着您可以重复使用处理该句柄的代码(幸运的是,无需将其存储在 indexedDB 中,只需在需要时获取即可)。假设您已在 OPFS 中已有一些文件并且想要使用这些文件,那么通过使用之前显示的函数 iterateAsyncDirEntries(),您可以执行如下操作:

return navigator.storage.getDirectory().then(function (dirHandle) {
  let entries = dirHandle.entries();
  return iterateAsyncDirEntries(entries, [])
    .then(function (archiveList) {
      return archiveList;
    })
    .catch(function (err) {
      console.error('Unable to iterate OPFS entries', err);
    });
});

别忘了,您仍然需要对 archiveList 数组中要处理的任何条目使用 getFile()

将文件导入 OPFS

那么,您首先如何将文件导入 OPFS?没这么快!首先,您需要估算所使用的存储空间大小,并确保用户不会尝试在大小不合适的情况下尝试放入 97 GB 的文件。

估算配额非常简单: navigator.storage.estimate().then(function (estimate) { … });。但要研究如何向用户显示此内容,则稍有难度。在 Kiwix 应用中,我们选择在复选框旁边显示一个小应用内面板,该面板可让用户试用 OPFS:

显示已使用存储空间百分比和剩余可用存储空间(以 GB 为单位)的面板。

面板使用 estimate.quotaestimate.usage 填充,例如:

let OPFSQuota; // Global variable, so we don't have to keep checking it
return navigator.storage.estimate().then(function (estimate) {
  const percent = ((estimate.usage / estimate.quota) * 100).toFixed(2);
  OPFSQuota = estimate.quota - estimate.usage;
  document.getElementById('OPFSQuota').innerHTML =
    '<b>OPFS storage quota:</b><br />Used:&nbsp;<b>' +
    percent +
    '%</b>; ' +
    'Remaining:&nbsp;<b>' +
    (OPFSQuota / 1024 / 1024 / 1024).toFixed(2) +
    '&nbsp;GB</b>';
});

如您所见,还有一个按钮,可让用户从用户可见的文件系统将文件添加到 OPFS。这样做的好消息是,您只需使用 File API 即可获取要导入的所需一个或多个 File 对象。实际上,切勿使用 window.showOpenFilePicker(),因为 Firefox 不支持此方法,而 OPFS 最绝对受支持。

您在上方屏幕截图中看到的可见 Add file(s) 按钮不是旧版文件选择器,但在用户点击或点按该按钮时,它会 click() 隐藏的旧版选择器(<input type="file" multiple … /> 元素)。然后,应用只会捕获隐藏文件输入的 change 事件,检查文件的大小,如果文件太大而无法达到配额,应用会拒绝这些文件。如果一切顺利,请询问用户是否要添加这些组件:

archiveFilesLegacy.addEventListener('change', function (files) {
  const filesArray = Array.from(files.target.files);
  // Abort if user didn't select any files
  if (filesArray.length === 0) return;
  // Calculate the size of the picked files
  let filesSize = 0;
  filesArray.forEach(function (file) {
    filesSize += file.size;
  });
  // Check the size of the files does not exceed the quota
  if (filesSize > OPFSQuota) {
    // Oh no, files are too big! Tell user...
    console.log('Files would exceed the OPFS quota!');
  } else {
    // Ask user if they're sure... if user said yes...
    return importOPFSEntries(filesArray)
      .then(function () {
        // Tell user we successfully imported the archives
      })
      .catch(function (err) {
        // Tell user there was an error (error catching is important!)
      });
  }
});

询问用户是否要将 .zim 文件列表添加到源私有文件系统的对话框。

由于在某些操作系统(如 Android)上,导入归档的操作不是最快捷的操作,因此 Kiwix 在导入归档时还会显示一个横幅和一个小旋转图标。该团队并未研究如何为此操作添加进度指示器:如果您成功了,请在明信片上回答问题!

那么,Kiwix 如何实现 importOPFSEntries() 函数?这涉及使用 fileHandle.createWriteable() 方法,该方法实际上允许将每个文件流式传输到 OPFS 中。所有艰苦的工作都由浏览器处理。(Kiwix 在此处使用 Promise 是出于与我们的旧版代码库有关的原因,但必须指出,在这种情况下,await 会生成更简单的语法,并避免了末日效应。)

function importOPFSEntries(files) {
  // Get a handle on the OPFS directory
  return navigator.storage
    .getDirectory()
    .then(function (dir) {
      // Collect the promises for each file that we want to write
      let promises = files.map(function (file) {
        // Create the file and get a writeable handle on it
        return dir
          .getFileHandle(file.name, { create: true })
          .then(function (fileHandle) {
            // Get a writer for the file
            return fileHandle.createWritable().then(function (writer) {
              // Show a banner / spinner, then write the file
              return writer
                .write(file)
                .then(function () {
                  // Finished with this writer
                  return writer.close();
                })
                .catch(function (err) {
                  console.error('There was an error writing to the OPFS!', err);
                });
            });
          })
          .catch(function (err) {
            console.error('Unable to get file handle from OPFS!', err);
          });
      });
      // Return a promise that resolves when all the files have been written
      return Promise.all(promises);
    })
    .catch(function (err) {
      console.error('Unable to get a handle on the OPFS directory!', err);
    });
}

将文件流直接下载到 OPFS 中

一个变体是能够将文件从互联网直接流式传输到 OPFS 中,或流式传输到具有目录句柄的任何目录(即使用 window.showDirectoryPicker() 选择的目录)。它采用的原则与上述代码相同,但构造的 ResponseReadableStream 和将远程文件读取的字节加入队列的控制器组成。然后,生成的 Response.body 会通过管道传送到 OPFS 内的新文件的写入者中。

在这种情况下,Kiwix 能够统计通过 ReadableStream 传递的字节数,因此应向用户提供进度指示器,并警告他们不要在下载期间退出应用。代码有点复杂,无法在这里展示,但由于我们的应用是一款 FOSS 应用,因此如果您有兴趣进行类似操作,可以查看源代码。Kiwix 界面如下所示(下面显示的不同进度值是因为它仅在百分比变化时更新横幅,但会更频繁地更新 Download progress 面板):

Kiwix 的界面底部有一个栏,警告用户不要退出应用,并显示 .zim 归档文件的下载进度。

由于下载可能需要很长时间,因此 Kiwix 允许用户在操作期间自由使用应用,但会始终显示横幅,以便提醒用户在下载操作完成之前不要关闭应用。

在应用中实现迷你文件管理器

此时,Kiwix PWA 开发者意识到,仅仅将文件添加到 OPFS 是不够的。该应用还需要为用户提供一种方法,使其能够从此存储区域中删除不再需要的文件,理想情况下,该应用还可以将 OPFS 中锁定的任何文件导出回用户可见的文件系统。实际上,有必要在应用中实现迷你文件系统

在此,我要向您简单介绍一下专为 Chrome 打造的精彩 OPFS Explorer 扩展程序(它也适用于 Edge)。它会在开发者工具中添加一个标签页,让您可以准确查看 OPFS 中的内容,还可删除流氓或失败的文件。它对于检查代码是否正常运行、监控下载行为以及全面清理开发实验来说,是非常重要的。

文件 export 取决于能否获取 Kiwix 将在其中保存导出文件的所选文件或目录的文件句柄,因此这仅适用于它可以使用 window.showSaveFilePicker() 方法的上下文。如果 Kiwix 文件小于几 GB,我们就可以在内存中构建一个 blob,并为其提供网址,然后将其下载到用户可见的文件系统中。遗憾的是,这无法实现如此庞大的归档。如果支持,导出相当简单:与将文件保存到 OPFS 中几乎一样,这与将文件保存到 OPFS 中(获取要保存的文件的句柄,要求用户使用 window.showSaveFilePicker() 选择要保存到的位置,然后使用 saveHandle 中的 createWriteable())。您可以在代码库中查看相关代码

所有浏览器都支持文件删除,只需使用一个简单的 dirHandle.removeEntry('filename') 即可实现。在 Kiwix 中,我们首选像上述方法那样迭代 OPFS 条目,以便先检查所选文件是否存在并请求确认,但这可能不是每个人都需要的。同样,如果感兴趣,您可以查看我们的代码

他们决定不在 Kiwix 界面上使用提供这些选项的按钮,而是将小图标直接放置在归档列表下方。点按其中某个图标会更改归档列表的颜色,以直观地告知用户将要执行的操作。然后,用户点击或点按其中一个归档文件,系统会执行相应的操作(导出或删除)(确认后)。

询问用户是否要删除 .zim 文件的对话框。

最后,这里是上述所有文件管理功能的抓屏演示 - 将文件添加到 OPFS、直接将文件下载到其中、删除文件以及导出到用户可见的文件系统。

开发者的工作永无止境

对于 PWA 开发者来说,OPFS 是一项伟大的创新,它提供非常强大的文件管理功能,对于缩小原生应用和 Web 应用之间的差距大有帮助。但开发者却是一群可悲;他们从来都不是很满意!OPFS 几乎是完美的,但还不太完美...这些主要功能在 Chromium 和 Firefox 浏览器中均可使用,而且在 Android 和桌面设备上都实现了,这真是太好了。我们希望很快就能在 Safari 和 iOS 中实现全套功能。仍然存在以下问题:

  • 无论底层磁盘空间有多少,Firefox 的 OPFS 配额上限均为 10GB。虽然对于大多数 PWA 作者而言,这已经足够,但对于 Kiwix 来说,这是非常严格的限制。幸运的是,Chromium 浏览器的功能更加强大
  • 目前无法将大型文件从 OPFS 导出到移动浏览器或桌面版 Firefox 中用户可见的文件系统,因为未实现 window.showSaveFilePicker()。在这些浏览器中,大型文件实际上会限制在 OPFS 中。这违背了 Kiwix 的开放访问内容的理念,以及用户之间共享归档文件的能力,尤其是在互联网连接断断续续或成本高昂的领域。
  • 用户无法控制 OPFS 虚拟文件系统将使用的存储空间。这在移动设备上尤其容易出现问题:用户的 microSD 卡存储空间可能很大,但设备存储空间却非常小。

但总而言之,这些是 PWA 中文件访问的巨大进步。Kiwix PWA 团队非常感谢 Chromium 开发者和倡导者,他们最先提出并设计了 File System Access API,也衷心感谢浏览器供应商就源私有文件系统的重要性达成共识的辛勤工作。对 Kiwix JS PWA 而言,它解决了过去影响该应用的许多用户体验问题,并帮助我们致力于为所有人改善 Kiwix 内容的无障碍功能。请试用 Kiwix PWA,并告诉开发者您的想法!

有关 PWA 功能的一些实用资源,请参阅以下网站: