Kiwix PWA를 통해 사용자가 오프라인에서 사용하기 위해 인터넷의 데이터를 GB 단위로 저장할 수 있는 방법

사람들이 심플한 테이블 위에 노트북 주위에 서 있고 왼쪽에는 플라스틱 의자가 있습니다. 배경은 개발도상국의 학교처럼 보입니다.

이 우수사례에서는 비영리 단체인 Kiwix에서 Progressive Web App 기술과 File System Access API를 사용하여 사용자가 대규모 인터넷 보관 파일을 다운로드하여 오프라인에서 사용할 수 있도록 하는 방법을 살펴봅니다. 파일 관리를 개선하여 권한 메시지 없이 보관 파일에 대한 향상된 액세스를 제공하는 Kiwix PWA 내의 새로운 브라우저 기능인 OPFS (Origin Private File System)를 다루는 코드의 기술 구현에 관해 알아보세요. 이 문서에서는 이러한 새로운 파일 시스템의 당면 과제와 향후 개발 가능성을 설명합니다.

Kiwix 정보

국제전기통신연합(International Telecommunication Union)에 따르면 웹이 탄생한 지 30년이 지난 지금도 전 세계 인구의 3분의 1이 여전히 안정적인 인터넷 액세스를 기다리고 있습니다. 여기서 이야기가 끝나는 건가요? 물론 아닐 것입니다 스위스에 기반을 둔 비영리 단체인 Kiwix의 사람들은 인터넷 액세스가 제한되거나 없는 사람들에게 지식을 제공하는 것을 목표로 하는 오픈소스 앱 및 콘텐츠로 구성된 생태계를 개발했습니다. 이들은 개발자가 인터넷에 쉽게 액세스할 수 없는 경우 누군가가 연결을 사용할 수 있는 위치와 시기에 중요한 리소스를 대신 다운로드하여 나중에 오프라인에서 사용할 수 있도록 로컬에 저장할 수 있다고 생각합니다. Wikipedia, Project Gutenberg, Stack Exchange, 심지어 TED 강연과 같은 많은 중요한 사이트를 ZIM 파일이라고 하는 고도로 압축된 아카이브로 변환하고 Kiwix 브라우저를 통해 즉시 읽을 수 있습니다.

ZIM 보관 파일은 일반적으로 HTML, JavaScript, CSS를 저장하는 데 매우 효율적인 Zstandard(ZSTD) 압축을 사용합니다 (이전 버전은 XZ를 사용함). 반면 이미지는 일반적으로 압축된 WebP 형식으로 변환됩니다. 또한 각 ZIM에는 URL과 제목 색인도 포함됩니다. 여기에서는 압축이 중요합니다. 왜냐하면 영어로 된 전체 Wikipedia 전체 (640만 개의 기사와 이미지)가 ZIM 형식으로 변환되면 97GB로 압축되기 때문입니다. ZIM 형식으로 변환하면 모든 인간 지식의 합계가 이제 중급 Android 휴대전화에서도 적합하다는 사실을 깨닫게 될 때까지는 97GB로 압축됩니다. 수학, 의학 등 위키백과의 테마 버전을 포함하여 여러 소규모 리소스도 제공됩니다.

Kiwix는 데스크톱 (Windows/Linux/macOS) 및 모바일 (iOS/Android) 사용을 타겟팅하는 다양한 네이티브 앱을 제공합니다. 그러나 이 우수사례에서는 최신 브라우저가 있는 모든 기기를 위한 범용적이고 간단한 솔루션을 목표로 하는 프로그레시브 웹 앱 (PWA)에 중점을 둡니다.

완전히 오프라인으로 대용량 콘텐츠 보관 파일에 빠르게 액세스할 수 있는 범용 웹 앱과 혁신적이고 흥미로운 솔루션을 제공하는 일부 최신 JavaScript API, 특히 File System Access APIOrigin Private File System의 개발에서 발생하는 과제를 살펴보겠습니다.

오프라인용 웹 앱을 찾으시나요?

Kiwix 사용자는 다양한 요구사항이 있는 다양한 사용자이며 Kiwix는 콘텐츠에 액세스할 기기와 운영체제를 거의 또는 전혀 제어할 수 없습니다. 이러한 기기 중 일부는 특히 세계 저소득 지역에서 느리거나 구식일 수 있습니다. Kiwix는 최대한 많은 사용 사례를 지원하려고 노력하고 있지만, 모든 기기에서 가장 보편적인 소프트웨어인 웹브라우저를 사용하면 더 많은 사용자에게 도달할 수 있다는 사실도 깨달았습니다. 따라서 JavaScript로 작성할 수 있는 모든 애플리케이션은 결국 JavaScript로 작성된다는 애우드의 법칙에서 영감을 받아 약 10년 전 Kiwix 소프트웨어를 C++에서 JavaScript로 포팅할 준비를 갖추게 되었습니다.

Kiwix HTML5라고 하는 이 포트의 첫 번째 버전은 지금은 더 이상 사용되지 않는 Firefox OS용과 브라우저 확장 프로그램용이었습니다. 핵심에는 Emscripten 컴파일러를 사용하여 ASM.js의 중간 JavaScript 언어 및 나중에 Wasm, 즉 WebAssembly로 컴파일된 C++ 압축 해제 엔진(XZ 및 ZSTD)이 있었습니다. 나중에 Kiwix JS로 이름이 변경된 브라우저 확장 프로그램은 여전히 활발하게 개발되고 있습니다.

Kiwix JS 오프라인 브라우저

프로그레시브 웹 앱(PWA)을 입력합니다. 이 기술의 잠재력을 깨달은 Kiwix 개발자는 Kiwix JS 전용 PWA 버전을 빌드하고 OS 통합을 추가하여 특히 오프라인 사용, 설치, 파일 처리, 파일 시스템 액세스 영역에서 앱이 네이티브와 유사한 기능을 제공할 수 있도록 했습니다.

오프라인 우선 PWA는 매우 가벼우므로 모바일 인터넷이 간헐적이거나 비용이 많이 드는 상황에 적합합니다. 이를 뒷받침하는 기술은 Service Worker API 및 관련 Cache API로, Kiwix JS를 기반으로 하는 모든 앱에서 사용됩니다. 이러한 API를 사용하면 앱이 서버 역할을 하여 조회 중인 기본 문서나 기사의 가져오기 요청을 가로채고 (JS) 백엔드로 리디렉션하여 ZIM 보관 파일에서 응답을 추출하고 구성할 수 있습니다.

어디서나 사용 가능한 스토리지

특히 휴대기기에서의 ZIM 보관 파일, 저장소, 액세스는 용량이 크다는 점을 감안할 때 Kiwix 개발자에게는 가장 큰 골칫거리일 것입니다. 많은 Kiwix 최종 사용자는 인터넷을 사용할 수 있을 때 나중에 오프라인으로 사용하기 위해 앱 내에서 콘텐츠를 다운로드합니다. 다른 사용자는 토렌트를 사용하여 PC에 다운로드한 다음 휴대기기나 태블릿 기기로 전송하고, 일부가 잘 맞지 않거나 모바일 인터넷이 비싼 지역에서 USB 스틱이나 휴대용 하드 드라이브의 콘텐츠를 교환합니다. 사용자가 액세스 가능한 임의의 위치에서 콘텐츠에 액세스하는 이러한 모든 방법은 Kiwix JS 및 Kiwix PWA에서 지원해야 합니다.

처음에 Kiwix JS가 메모리 용량이 낮은 기기에서도 수백 GB(ZIM 보관 파일 중 하나는 166GB)의 방대한 보관 파일을 읽을 수 있었던 것은 File API입니다. 이 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 객체를 보유합니다. 기술적으로 순수한 클라이언트 측 JavaScript로 작성된 Kiwix의 객체 지향 백엔드는 필요에 따라 대형 보관 파일의 작은 조각을 읽습니다. 슬라이스를 압축 해제해야 하는 경우 백엔드는 Wasm 압축 해제기에 이를 전달하고, 요청 시 전체 blob (일반적으로 기사 또는 애셋)이 압축 해제될 때까지 추가 슬라이스를 가져옵니다. 즉, 대용량 보관 파일을 완전히 메모리로 읽을 필요가 없습니다.

범용성인 File API에는 네이티브 앱에 비해 Kiwix JS 앱이 투박하고 오래된 것처럼 보이게 하는 단점이 있습니다. 사용자가 파일 선택 도구를 사용하여 보관 파일을 선택하거나 앱이 실행될 때마다 앱으로 파일을 드래그 앤 드롭해야 합니다. 이 API를 사용하면 한 세션에서 다음 세션까지 액세스 권한을 유지할 방법이 없기 때문입니다.

많은 개발자와 마찬가지로 Kiwix JS 개발자들은 이러한 열악한 UX를 완화하기 위해 처음에는 Electron을 이용했습니다. ElectronJS는 노드 API를 사용하는 파일 시스템에 대한 전체 액세스를 비롯한 강력한 기능을 제공하는 놀라운 프레임워크입니다. 그러나 다음과 같이 잘 알려진 단점이 있습니다.

  • 데스크톱 운영체제에서만 실행됩니다.
  • 용량이 크고 무겁습니다 (70MB~100MB).

Chromium의 전체 사본이 모든 앱에 포함되어 있다는 사실로 인해 Electron 앱의 크기는 최소화되고 번들된 PWA의 경우 단순한 5.1MB에 비해 매우 부적절합니다.

그렇다면 Kiwix가 PWA 사용자의 상황을 개선할 수 있는 방법이 있었나요?

File System Access API로 복구

2019년경 Kiwix는 Chrome 78에서 오리진 트라이얼을 진행한 다음 Native File System API라고 부르는 새로운 API에 관해 알게 되었습니다. 이 라이브러리는 파일이나 폴더의 파일 핸들을 가져와 IndexedDB 데이터베이스에 저장하는 기능을 제공했습니다. 결정적으로 이 핸들은 앱 세션 간에 유지되므로 사용자가 앱을 다시 실행할 때 파일이나 폴더를 다시 선택할 필요가 없습니다 (단, 빠른 권한 메시지에 응답해야 함). 프로덕션에 도달했을 무렵에는 File System Access API로 이름이 변경되었으며 핵심 부분은 WhatWG에 의해 File System API(FSA)로 표준화되었습니다.

그러면 API의 파일 시스템 액세스 부분은 어떻게 작동할까요? 다음은 몇 가지 중요한 사항입니다.

  • 비동기 API입니다 (웹 작업자의 특수 함수 제외).
  • 파일 또는 디렉터리 선택 도구는 사용자 동작 (UI 요소 클릭 또는 탭)을 캡처하여 프로그래매틱 방식으로 실행해야 합니다.
  • 사용자가 (새 세션에서) 이전에 선택한 파일에 액세스할 수 있는 권한을 다시 부여하려면 사용자 동작도 필요합니다. 실제로는 브라우저가 사용자 동작으로 시작되지 않은 경우 권한 메시지 표시를 거부합니다.

코드는 비교적 단순하지만, 투박한 IndexedDB API를 사용하여 파일과 디렉터리 핸들을 저장해야 한다는 점을 제외하면 비교적 간단합니다. 다행히도 browser-fs-access와 같이 까다로운 작업을 자동으로 처리하는 라이브러리가 몇 가지 있습니다. Kiwix JS에서 우리는 API와 직접 작업하기로 했는데, API는 매우 잘 문서화되어 있습니다.

파일 및 디렉터리 선택 도구 열기

파일 선택 도구를 열면 다음과 같이 표시됩니다 (여기서는 프로미스를 사용하지만 async/await 슈가를 선호한다면 개발자용 Chrome 튜토리얼 참고).

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.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 functionconst file = await entry.getFile()를 사용하여 이에 상응하는 파일을 가져와야 합니다.

더 나아가서

사용자가 앱의 후속 실행 시 사용자 동작으로 시작된 권한을 부여해야 하므로 파일과 폴더를 (다시) 여는 데 약간의 마찰이 추가되지만 파일을 강제로 다시 선택하도록 하는 것보다 훨씬 더 유동적입니다. Chromium 개발자는 현재 설치된 PWA에 영구 권한을 허용하는 코드를 완료하고 있습니다. 많은 PWA 개발자가 이러한 기능을 요구해왔으며 기대되는 부분입니다.

만일 기다릴 필요가 없다면 어떻게 해야 할까요? Kiwix 개발자들은 최근 Chromium과 Firefox 브라우저에서 지원하는 File Access API의 새로운 기능 (Safari에서 부분적으로 지원되지만 여전히 FileSystemWritableFileStream은 누락됨)을 사용하여 지금 당장 모든 권한 프롬프트를 제거할 수 있다는 사실을 발견했습니다. 이 새로운 기능은 Origin Private File System입니다.

완전한 네이티브로 전환: 오리진 비공개 파일 시스템

Origin Private File System(OPFS)은 여전히 Kiwix PWA의 실험적 기능이지만, 에서는 네이티브 앱과 웹 앱 사이의 격차를 대체로 해소해주므로 사용자가 OPFS를 사용해 볼 것을 적극 권장합니다. 주요 이점은 다음과 같습니다.

  • OPFS의 보관 파일은 실행 시에도 권한 안내 메시지 없이 액세스할 수 있습니다. 사용자는 아무런 문제 없이 이전 세션을 중단한 지점부터 계속해서 기사를 읽고 보관 파일을 찾아볼 수 있습니다.
  • 또한 저장된 파일에 고도로 최적화된 액세스를 제공하며 Android에서는 속도가 5~10배 더 빨라집니다.

File API를 사용하는 Android에서 표준 파일 액세스는 특히 Kiwix 사용자의 경우처럼 느립니다. 대용량 보관 파일이 기기 저장소가 아닌 microSD 카드에 저장된 경우 특히 그렇습니다. 이 새로운 API를 사용하면 이 모든 것이 변경됩니다. 대부분의 사용자는 97GB 파일을 OPFS에 저장할 수 없지만 (microSD 카드 저장소가 아닌 기기 저장용량을 사용), 중소 규모의 보관 파일을 저장하는 데 적합합니다. WikiProject Medicine에서 가장 완전한 의학 백과사전을 확인하고 싶으신가요? 걱정하지 마세요. 1.7GB로 OPFS에 적합합니다. (팁: 인앱 라이브러리에서 othermdwiki_en_all_maxi를 찾으세요.)

OPFS의 작동 방식

OPFS는 브라우저에서 제공하는 파일 시스템으로, 출처마다 별개이며 Android의 앱 범위 지정 저장소와 유사하다고 볼 수 있습니다. 파일은 사용자에게 표시되는 파일 시스템에서 OPFS로 가져오거나 직접 다운로드할 수 있습니다 (API를 통해 OPFS에 파일을 만들 수도 있음). 일단 OPFS에 들어가면, 이러한 키는 나머지 기기와 격리됩니다. 데스크톱 Chromium 기반 브라우저의 경우 OPFS에서 사용자에게 표시되는 파일 시스템으로 파일을 다시 내보낼 수도 있습니다.

OPFS를 사용하려면 먼저 navigator.storage.getDirectory()를 사용하여 OPFS에 대한 액세스를 요청해야 합니다. 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로 가져오려면 어떻게 해야 할까요? 그렇지 않습니다. 먼저 작업해야 하는 저장용량을 추정해야 하고 사용자가 97GB 파일이 적합하지 않다고 하면 파일을 넣으려고 하지 않도록 해야 합니다.

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를 사용하여 가져올 파일 객체 (들)를 가져오기만 하면 된다는 것입니다. 실제로 window.showOpenFilePicker()는 사용하지 않는 것이 중요합니다. 이 방법은 Firefox에서 지원하지 않지만 OPFS는 가장 확실하게 지원되기 때문입니다.

위 스크린샷에 표시된 파일 추가 버튼은 기존 파일 선택 도구가 아니지만, 클릭하거나 탭하면 숨겨진 기존 선택 도구(<input type="file" multiple … /> 요소)를 click()합니다. 그러면 앱이 숨겨진 파일 입력의 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() 함수를 어떻게 구현했을까요? 여기에는 각 파일을 OPFS로 효과적으로 스트리밍할 수 있는 fileHandle.createWriteable() 메서드를 사용합니다. 모든 힘든 작업은 브라우저에서 처리합니다. (Kiwix는 기존 코드베이스와 관련된 이유로 여기서 프로미스를 사용하지만, 여기서는 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()로 선택한 디렉터리)로 직접 스트리밍할 수 있는 기능이 변형되었습니다. 이는 위의 코드와 동일한 원칙을 사용하지만 ReadableStream 및 원격 파일에서 읽은 바이트를 대기열에 추가하는 컨트롤러로 구성된 Response를 구성합니다. 그런 다음 결과 Response.body는 OPFS 내의 새 파일 작성자로 파이핑됩니다.

이 경우 Kiwix는 ReadableStream를 통해 전달되는 바이트를 계산할 수 있으므로 사용자에게 진행률 표시기를 제공하고 다운로드 중에 앱을 종료하지 말라고 경고합니다. 코드가 너무 복잡하여 여기서 보여주기에는 어렵지만, 앱이 FOSS 앱이므로 유사한 작업을 하고 싶다면 소스를 확인하면 됩니다. 다음은 Kiwix UI입니다. 아래와 같이 진행률 값이 다른 이유는 비율이 변경될 때만 배너를 업데이트하지만 다운로드 진행률 패널을 더 정기적으로 업데이트하기 때문입니다.

하단에 바가 있는 Kiwix 사용자 인터페이스에 앱을 종료하지 말라는 경고와 .zim 보관 파일의 다운로드 진행률이 표시되어 있습니다.

다운로드는 상당히 긴 작업이 될 수 있으므로 Kiwix에서는 작업 중에 사용자가 앱을 자유롭게 사용할 수 있지만 배너는 항상 표시되도록 합니다. 따라서 다운로드 작업이 완료될 때까지 앱을 닫지 말라는 알림이 사용자에게 표시됩니다.

인앱 미니 파일 관리자 구현

이 시점에서 Kiwix PWA 개발자는 OPFS에 파일을 추가하는 것만으로는 충분하지 않다는 사실을 깨달았습니다. 또한 앱은 사용자가 이 저장소 영역에서 더 이상 필요하지 않은 파일을 삭제하고 OPFS에 잠긴 파일을 사용자에게 표시되는 파일 시스템으로 다시 내보낼 수 있는 방법을 제공해야 했습니다. 실질적으로 앱 내에 미니 파일 관리 시스템을 구현해야 했습니다.

Chrome용 OPFS Explorer 확장 프로그램(Edge에서도 작동)을 간단히 소개합니다. 또한 개발자 도구에 OPFS에 있는 항목을 정확하게 볼 수 있는 탭을 추가하고, 악의적인 파일이나 실패한 파일도 삭제할 수 있습니다. 이 실험은 코드의 작동 여부를 확인하고, 다운로드 동작을 모니터링하고, 전반적인 개발 실험을 정리하는 데 큰 도움이 되었습니다.

파일 내보내기는 Kiwix가 내보낸 파일을 저장할 선택한 파일 또는 디렉터리의 파일 핸들을 가져오는 기능에 따라 달라지므로 이 기능은 window.showSaveFilePicker() 메서드를 사용할 수 있는 컨텍스트에서만 작동합니다. Kiwix 파일이 몇 GB보다 작으면 메모리에 blob을 구성하고 URL을 제공한 다음 사용자에게 표시되는 파일 시스템으로 다운로드할 수 있습니다. 유감스럽게도 이러한 대용량 보관 파일에서는 불가능합니다. 지원되는 경우 내보내기는 매우 간단합니다. 반대로 파일을 OPFS에 저장하는 과정과 거의 동일합니다. 저장할 파일의 핸들을 가져오고 사용자에게 window.showSaveFilePicker()로 저장할 위치를 선택하도록 요청한 다음 saveHandle에서 createWriteable()를 사용합니다. 저장소에서 코드를 확인할 수 있습니다.

파일 삭제는 모든 브라우저에서 지원되며 간단한 dirHandle.removeEntry('filename')를 사용하면 됩니다. Kiwix의 경우 위에서 한 것처럼 OPFS 항목을 반복하는 것을 선호했습니다. 이렇게 하면 선택한 파일이 먼저 있는지 확인하고 확인을 요청할 수 있지만, 모든 사용자에게 필수는 아닐 수 있습니다. 원하는 경우 다시 코드를 검토할 수 있습니다.

이러한 옵션을 제공하는 버튼으로 Kiwix UI를 복잡하게 만들지 않고 대신 보관 파일 목록 바로 아래에 작은 아이콘을 배치하기로 했습니다. 이러한 아이콘 중 하나를 탭하면 보관처리 목록의 색상이 변경되어 사용자가 실행할 작업을 시각적으로 확인할 수 있습니다. 그런 다음 사용자가 보관 파일 중 하나를 클릭하거나 탭하면 해당 작업 (내보내기 또는 삭제)이 실행됩니다(확인 후).

사용자에게 .zim 파일을 삭제할지 묻는 대화상자

마지막으로 다음은 OPFS에 파일을 추가하고, OPFS에 파일을 직접 다운로드하고, 파일을 삭제하고, 사용자에게 표시되는 파일 시스템으로 내보내기 등 위에서 설명한 모든 파일 관리 기능의 스크린캐스트 데모입니다.

개발자의 일은 끝나지 않습니다

OPFS는 PWA 개발자를 위한 훌륭한 혁신으로, 네이티브 앱과 웹 앱 사이의 격차를 줄이는 데 도움이 되는 매우 강력한 파일 관리 기능을 제공합니다. 하지만 개발자는 불행한 집단입니다. 그다지 만족스럽지 않습니다. OPFS는 거의 완벽하지만 완벽하지는 않습니다. 주요 기능이 Chromium과 Firefox 브라우저 모두에서 작동하고 Android는 물론 데스크톱에도 구현된다는 점이 좋습니다. 모든 기능이 조만간 Safari와 iOS에도 구현될 수 있기를 바랍니다. 다음과 같은 문제가 남아 있습니다.

  • Firefox는 현재 기본 디스크 공간이 얼마나 많은지에 관계없이 OPFS 할당량을 10GB로 제한합니다. 대부분의 PWA 작성자에게는 충분한 한도이지만 Kiwix의 경우에는 상당히 제한적입니다. 다행히 Chromium 브라우저는 훨씬 관대합니다.
  • 현재는 window.showSaveFilePicker()가 구현되지 않아 OPFS에서 모바일 브라우저나 데스크톱 Firefox의 사용자에게 표시되는 파일 시스템으로 대용량 파일을 내보낼 수 없습니다. 이러한 브라우저에서는 대형 파일이 OPFS에 효과적으로 트랩됩니다. 이는 콘텐츠에 대한 개방적인 액세스, 특히 인터넷 연결이 간헐적이거나 비용이 많이 드는 지역에서 사용자 간에 보관 파일을 공유할 수 있는 Kiwix의 정신에 어긋납니다.
  • 사용자는 OPFS 가상 파일 시스템에서 사용할 스토리지를 제어할 수 없습니다. 이는 사용자가 microSD 카드에 많은 공간이 있지만 기기 저장소에는 매우 작은 공간이 있을 수 있는 휴대기기에서 특히 문제가 됩니다.

하지만 이 모든 것이 PWA에서의 파일 액세스에 있어 엄청난 진전을 이룰 수 있는 사소한 문제에 해당합니다. Kiwix PWA팀은 File System Access API를 처음 제안하고 설계한 Chromium 개발자 및 지지자들과 브라우저 공급업체들 사이에서 Origin Private File System의 중요성에 대한 합의를 이루기 위해 노력해 주신 Chromium 개발자 및 지지자들에게 깊은 감사를 전합니다. Kiwix JS PWA의 경우, 과거에 앱을 고갈시켰던 많은 UX 문제를 해결했으며, 모두를 위해 Kiwix 콘텐츠의 접근성을 개선하는 데 도움이 되었습니다. Kwix PWA를 사용해 보고 개발자에게 의견을 전달해 주세요.

PWA 기능에 대한 몇 가지 훌륭한 리소스는 다음 사이트를 참고하세요.