マルチデバイス間でファイル転送を手軽にしたい！スマホでもPCでもcurlでも

「[ネットワーク越しでパイプしたり、あらゆるデバイス間でデータ転送したい！ - Qiita](https://qiita.com/nwtgck/items/78309fc529da7776cba0)」で登場した[Piping Server](https://github.com/nwtgck/piping-server)を使ったWebアプリです。
ネット上でも気に入っている人も見かけ、スマホ<=>PC間などでの活用してる声が多めでした。TypeScript + Vue + Vuetifyで手軽にファイルを転送するWebアプリが出来ました。
<br>
<b>Piping UI</b>: <https://piping-ui.org> と呼んでます。
以下のデモはiPhone => PCへの送信です。
[![piping-ui-ja-iphone-to-pc-short.mp4.opt.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84451/5f8300c5-def7-14c9-945f-45ee7fb59d3e.gif)](https://piping-ui.org)

以下で機能の紹介と少し技術的なことに触れていきたいと思います。
GitHubリポジトリ: <https://github.com/nwtgck/piping-ui-web>

- アカウント登録なし
- 事前の設定なし
- 特別なアプリのインストールなし
- curl(CLI)との相性の良いPiping Server経由
- Progressive Web Apps (PWA)
- ダークテーマ対応
- 英語/日本語
- 複数ファイル転送
- サーバーURLとパスの記憶と自動補完
- 画像/動画のプレビュー
- 静的ホスト - [Netlify](https://www.netlify.com/)でビルド/デプロイ
- [パスワードでE2E暗号化](https://scrapbox.io/nwtgck/Piping_UI:_%E3%82%BB%E3%82%AD%E3%83%A5%E3%82%A2%E3%81%ABOpenPGP%E3%81%A7%E6%9A%97%E5%8F%B7%E5%8C%96%E3%81%97%E3%81%A6%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E8%BB%A2%E9%80%81%E3%81%99%E3%82%8B%E6%8A%80%E8%A1%93%E5%91%A8%E3%82%8A)

<!-- ![piping-ui-ja-iphone-to-pc.mp4.opt.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84451/ef414aa0-0c13-8534-bb5f-4911f4921c59.gif) -->

## アップロードとダウンロードを同時に
![piping-ui-ja-100MB-transfer.mp4.opt.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84451/373b23be-90a3-be96-8b02-9af1fa4b1485.gif)

この転送ツールの一番の特徴が、アップロード中にダウンロード出来ることだと思います。そのため送信者のアップロードの完了を待つ必要もなくダウンロードが可能です。特に大きいファイルの転送の際に発揮する機能です。データがサーバーに保存されることはなくデータをずっと流し込むことができ、前回の実験では少なくとも868テラバイト流し込むことができました。

> Piping Serverで無限にデータを送り続ける実験を47日前に開始しました。現在も進行中で、その結果では現在868テラバイトのデータが転送できています。

引用元: 「[ネットワーク越しでパイプしたり、あらゆるデバイス間でデータ転送したい！ - Qiita](https://qiita.com/nwtgck/items/78309fc529da7776cba0#_reference-7e9bdd074fa006d4007c)」


Piping ServerのBack pressure(背圧制御)が上手いこと効いて、受信者が遅ければアップロードしすぎないように調節されます。
参考: [Piping ServerでBack pressure(背圧制御)が効いてそうなことが分かるデモ動画 - TypeScript版とRust(Hyper)版 - nwtgck / Ryo Ota](https://scrapbox.io/nwtgck/Piping_Server%E3%81%A7Back_pressure(%E8%83%8C%E5%9C%A7%E5%88%B6%E5%BE%A1)%E3%81%8C%E5%8A%B9%E3%81%84%E3%81%A6%E3%81%9D%E3%81%86%E3%81%AA%E3%81%93%E3%81%A8%E3%81%8C%E5%88%86%E3%81%8B%E3%82%8B%E3%83%87%E3%83%A2%E5%8B%95%E7%94%BB_-_TypeScript%E7%89%88%E3%81%A8Rust(Hyper)%E7%89%88)


Service Workerを使ってクロスオリジンでストリーミングしながらダウンロードする技術に関して後述したいと思います。

## ダークテーマ対応
![piping-ui-ja-dark-theme.mp4.opt.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84451/ef70bbd6-5ba0-de87-37f8-54d37e9ee52e.gif)

最近のmacOSやiOSなどではOSでダークモードを優先するようにOSで設定出来るようになってます。デフォルトのテーマはこのマシンの設定をJavaScriptで読み込んでいます。具体的には`window.matchMedia('(prefers-color-scheme: dark)').matches`を使っています。Vuetify 2.xだと`this.$vuetify.theme.dark = true`でダークテーマに変更可能で、`<v-app :dark="isDark">`のようにdark属性は使わなくて良くなったようです。

参考:
- [WebアプリでmacOSのテーマ切り替えを取得する方法 - Qiita](https://qiita.com/unipota/items/ec552bd3d782844e65ef)
- [javascript - How to over ride css prefers-color-scheme setting - Stack Overflow](https://stackoverflow.com/questions/56300132/how-to-over-ride-css-prefers-color-scheme-setting/56550819#56550819)
- [window.matchMedia - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/Window/matchMedia)

## PWAのアップデート検知とアップデートボタン
![piping-ui-en-pwa-refresh.mp4.opt.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84451/4ce84848-e37a-5dbb-a78b-0446a45e7501.gif)

Progressive Web App (PWA)の更新があることを検知して更新ボタンを表示します。[TweetDeck](https://tweetdeck.twitter.com/)にこの機能があってずっと実現したかったことでした。ユーザーがインストール不要で使える手軽なPWAのアップデートをスムーズに行えてとても良いです。

「[Give Users Control Over App Updates in Vue CLI 3 PWAs](https://medium.com/@dougallrich/give-users-control-over-app-updates-in-vue-cli-3-pwas-20453aedc1f2)」を参考にして実現しました。この記事ではGoogleが作っている[Workbox](https://developers.google.com/web/tools/workbox)を使ったVueでのPWAの更新ボタンを生やせるようにする方法が紹介されています。

Piping UIでPWAの更新ボタンに対応した変更はこのコミット「<https://github.com/nwtgck/piping-ui-web/commit/e17639cb2237d3261136ecaf2b20f43cab69a623>」です。上記の記事で書かれている変更すべきファイルをざっと把握するときに使えるかもしれません。

以下のような流れで、更新ボタンが現れます。

- `src/registerServiceWorker.ts`の`updated()`で更新を検知して`CustomEvent`の'swUpdated'を出す =>
- `src/App.vue`で'swUpdated'のハンドラーの`this.showRefreshUI`が呼ばれる =>
- `showRefreshUI`内で`this.pwa.updateExists = true`に更新され、`App.vue`内の更新ボタンが表示される


## 複数ファイルを転送
![piping-ui-ja-zip-send.mp4.opt.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84451/0e27b778-c447-a545-6ab9-21d7e4d66ff7.gif)

同時に複数のファイルを転送できます。複数のファイルは.zipで一つにまとめられて送信されます。
.zipにする処理もクライントサイドで完結するために[JSZip](https://stuk.github.io/jszip/)を使っています。`XMLHttpRequest`や`fetch`のアップロード時にストリーミングして送信できるようになれば、.zipで圧縮しながら送信するみたいな`zip`コマンドと`curl`コマンドを組み合わせて送信できてたようなことが出来るようになります。

現在のブラウザでは、任意の[ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream)を`fetch`でアップロードできる方法が用意されていないようです。ですが嬉しいことに「[Uploading a Request made from a ReadableStream body by yutakahirano · Pull Request #425 · whatwg/fetch](https://github.com/whatwg/fetch/pull/425#issuecomment-518899855)」で、近いうちにChromiumにこの機能が搭載される予定があると中の人がコメントしています。この機能の実現にはすごく期待していて、圧縮や暗号化がストリーミングしながら出来るため時間的にもメモリ的にも効率性が非常に高いと思っています。


## URLをクリック可能に
![piping-ui-ja-url-send.mp4.opt.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84451/e57e9a78-b884-fd01-415d-a80d36b115a1.gif)

1行のURLに限らず、テキスト内に含まれるすべてのURLがクリック可能になります。[Linkify](https://github.com/SoapBox/linkifyjs)を使っています。この機能も含めPiping UI全体的に「[ふぁメモ](http://fa.hatenadiary.jp/)」さんの「[スマホ間でURLや画像の受け渡し (未完成) - ふぁメモ](http://fa.hatenadiary.jp/entry/20190211/1549817396)」の内容を取り入れています。この記事ではApple公式のアプリ「[ショートカット](https://apps.apple.com/jp/app/%E3%82%B7%E3%83%A7%E3%83%BC%E3%83%88%E3%82%AB%E3%83%83%E3%83%88/id915249334)」を使ってPiping Server経由でファイルやテキストを送る方法も紹介されています。

## 転送パスの自動補完
![piping-ui-ja-auto-complete-secret-path.mp4.opt.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84451/b966fc17-972b-d06d-f834-d9c52fa43feb.gif)

転送に使うパス(path)やサーバーのURLはLocal Storageに記憶されて今まで使ったものは補完が効きます。長いパスでも記憶させれば使いやすくなります。

## English/日本語対応とTypeScriptの型の活用
![piping-ui-en-to-ja.mp4.opt.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84451/2167be4c-909a-3248-1b93-f87add951d42.gif)

Androidの`strings.xml`に少し影響を受けた命名になってます。デフォルトの言語にある項目（フィールド）が他の言語にない場合は実行時にコンパイルエラーとして検出したり、パラメータを受け取って文字列を作り上げるときに順番を間違えにくくしたりする工夫をしました。

具体的には以下のように定義しています。

```ts:strings.ts
const en = {
  language: 'Language',
  dark_theme: 'Dark Theme',
  pwa_update: 'Update',
  version: `Version: ${VERSION}`,
  ...
  xhr_status_error: (p: {status: number, response: string}) => {
    return `Error (${p.status}): "${p.response}"`;
  },
  ...
};

const defaultStr = en;

const ja: typeof defaultStr = {
  language: '言語 (Language)',
  dark_theme: 'ダークテーマ',
  pwa_update: 'Update',
  version: `バージョン: ${VERSION}`,
  ...
  xhr_status_error: (p: {status: number, response: string}) => {
    return `エラー (${p.status}): "${p.response}"`;
  },
  ...
};
```

`const ja: typeof defaultStr`となっているため、`ja`は`en`にある項目がなかったり、余計な項目があるとコンパイル時にエラーとして検出可能です。またキー名だけではなく値の型も検査されるため、値が文字列でなくパラメータを受け取る関数であってもシグネチャが異なれば実行前にコンパイルエラーとして検出可能です。

以下の`strings()`関数を使って各言語のときの文字列を返すようにします。`strings()`は純粋関数のため、この言語切替の仕組みはVueなどとは切り離されていて、他のフレームワークへの移行などもしやすいのでないかと思っています。

```ts:strings.ts
export function strings(language: string): typeof defaultStr {
  if(language.startsWith("en")) {
    return en;
  } else if(language.startsWith("ja")) {
    return ja;
  } else {
    return defaultStr;
  }
}
```

Vueの中では以下のようにして使っています。

```ts
@Component
export default class MyComponent extends Vue {
  private get strings() {
    return strings(globalStore.language);
  }
}
```

`<template>`内で`{{ strings['cancel'] }}`とすれば、英語なら`{{ "Cancel" }}`に日本語なら`{{ "キャンセル" }}`になるイメージです。
`this.strings['cancel']`はパラメータなしの文字列ですが、引数を与える必要がある文字列も以下のように使うことが出来ます。

```ts
this.strings['xhr_status_error']({
  status: this.xhr.status,
  response: this.xhr.responseText
});
```
`<script lang="ts">`内に書いた場合はきちっとコンパイルエラーがあれば検出出来るので安全なのではないかと思っています。

## "https://"を自動補完
![piping-ui-ja-scheme-auto-complete.mp4.opt.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84451/d27a81bf-f9bb-bd96-bc2c-ea609a050c80.gif)
地味ですが便利です。中央集権を避け、手軽に他のサーバーURLに追加したり切り替えられるようにしたいです。

## OSSライセンス一覧
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84451/8bfb64c9-33eb-9861-89d3-373fd8b858e3.png" width="600">

一覧は`src/license.json`として生成されます。なるべく自動生成されるものをGit管理するのは避けたいため、npmスクリプトの`"posntinstall"`で[license-checker](https://github.com/davglass/license-checker)を使って`src/license.json`を生成しています。TypeScriptのコンパイラオプションで`"resolveJsonModule": true`を指定すると`import licenses from '@/licenses.json`できるようになるため、これでライセンス情報のJSONを読み込みます。

参考: [How to Import json into TypeScript - By](https://hackernoon.com/import-json-into-typescript-8d465beded79)

## 転送の仕組み

Web標準で使える`fetch()` ([fetch](https://developer.mozilla.org/ja/docs/Web/API/Fetch_API/Using_Fetch))を使って手軽にPiping Serverを経由してデータを転送できます。

```js:送信者
// 送信
fetch("https://ppng.ml/mypath1", {
  method: 'POST',
  body: 'hello!'
});
```

```js:受信者
// 受信
fetch("https://ppng.ml/mypath1")
  .then(res => res.text())
  .then(text => console.log(text));
```

送信者の`'hello!'`の代わりに`Blob`にしたり、`<input type="file">`からの`File`を入れたりすれば、テキストに限らず色んなデータをHTTPのPOSTとGETで手軽に送ることが出来ます。

受信側も`res.text()`の代わりに`res.blob()`や`res.arrayBuffer()`でバイナリを受け取れ、`res.body`（`ReadableStream`を返す）を使うことでストリーミングしながらのバイナリデータを読み取ることもできます。


## クロスオリジンでストリーミングしながらダウンロードする
やりたいことは、同一オリジンでない`Access-Control-Allow-Origin: *`なファイルをブラウザで表示させずに保存させたいです。`<a download>`を動的生成してダウンロードできそうですが、`"download"`属性は同一オリジンでないとダウンロード出来ないようです。
参考: <https://developer.mozilla.org/ja/docs/Web/HTML/Element/a#Attributes>

そこでこのブラウザの制限を回避するためにService Workerを使います。[StreamSaver.js](https://github.com/jimmywarting/StreamSaver.js)で使われているテクニックです。[Firefox Send](https://github.com/mozilla/send)でも使われていた思います。最小構成で再現した方法は「[ファイルのストリーミング強制保存をクロスオリジンでも実現させるService Workerの裏技ぽい使い方 - nwtgck / Ryo Ota](https://scrapbox.io/nwtgck/%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%81%AE%E3%82%B9%E3%83%88%E3%83%AA%E3%83%BC%E3%83%9F%E3%83%B3%E3%82%B0%E5%BC%B7%E5%88%B6%E4%BF%9D%E5%AD%98%E3%82%92%E3%82%AF%E3%83%AD%E3%82%B9%E3%82%AA%E3%83%AA%E3%82%B8%E3%83%B3%E3%81%A7%E3%82%82%E5%AE%9F%E7%8F%BE%E3%81%95%E3%81%9B%E3%82%8BService_Worker%E3%81%AE%E8%A3%8F%E6%8A%80%E3%81%BD%E3%81%84%E4%BD%BF%E3%81%84%E6%96%B9)」にまとめました。

Piping UIでのこの実装はこのコミット「<https://github.com/nwtgck/piping-ui-web/commit/31a8f7331d61aa0981337fe5df58fb731ac62f52>」です。上記の記事との違いはPiping UIではこのService Workerを使った方法に対応しているか確認するためのパスを用意したり、PWAのWorkboxを使ったものに適用したところです。

このストリーミングしながらのダウンロードに対応できるブラウザだと、以下の画像のようにダウンロードのURLが`https://piping-ui.org/sw-download?url=...&filename=...`のようになります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84451/16854f04-009a-fc55-451c-6e3007f5e352.png" width="600">

Piping UIはNetlifyにデプロイされた静的なファイルだけで構成されるWebアプリですが、`/sw-download`はService Workerがリバースプロキシのようになってリクエストをさばいて適切にPiping Server経由でデータをダウンロードします。

`vue-cli-service serve`だとService Workerが働かないので（webpack-dev-serverも同じかも）、ファイル監視してビルドする仕組みを作ってそれを使ってます (<https://github.com/nwtgck/watch-build-serve-node>)。検索しても見つからなかったので、こういう用途を満たすものがあったらそれに移行したいと思っています。

## curlユーザーとの互換性
以下はターミナル => Piping UIです。
![piping-ui-ja-terminal-to-ui-big.mp4.opt.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84451/b170c6cc-74ce-263d-91ce-51c91e2fe0b2.gif)

以下はPiping UI => ターミナルです。
![piping-ui-ja-ui-to-terminal-big.mp4.opt.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84451/c6d26927-f335-f949-e79b-9606b9670855.gif)

Piping UIではこの互換性をすごく大事にしました。`curl`や`wget`などが使える環境とブラウザを繋ぎやすくしたかったからです。送信は`... | curl -T - <URL>`, 受信は`curl <URL>`でシンプルなままです。
curlコマンドでの活用法は「[ネットワーク越しでパイプしたり、あらゆるデバイス間でデータ転送したい！ - Qiita](https://qiita.com/nwtgck/items/78309fc529da7776cba0)」にまとめています。

### curlとの互換 - チャット/通話/画面共有/エンドツーエンド暗号化
上記の`curl`との互換性の話です。

HTTPはボディをストリーミング可能です。ですが、残念ながら現在のブラウザ上で動くJavaScriptはストリームに弱いと感じてます。任意の`ReadableStream`がアップロード出来なかったり、前述したストリーミングしながらダウンロードする機能の話にも関連して弱いと感じてます。繰り返しになりますが、嬉しいことに「[Uploading a Request made from a ReadableStream body by yutakahirano · Pull Request #425 · whatwg/fetch](https://github.com/whatwg/fetch/pull/425#issuecomment-518899855)」で、近いうちにChromiumにこの機能が搭載される予定があると中の人がコメントしていてストリーミングしながらのアップロードも近い将来にブラウザ上で実現できそうです。

以下のWebアプリたちは、ブラウザ上で小さなチャンクに分けてPiping Serverを経由することで擬似的にストリーミングして実現しています。

- [Web上でPiping Server経由のエンドツーエンド暗号化したファイル転送 - nwtgck / Ryo Ota](https://scrapbox.io/nwtgck/Web%E4%B8%8A%E3%81%A7Piping_Server%E7%B5%8C%E7%94%B1%E3%81%AE%E3%82%A8%E3%83%B3%E3%83%89%E3%83%84%E3%83%BC%E3%82%A8%E3%83%B3%E3%83%89%E6%9A%97%E5%8F%B7%E5%8C%96%E3%81%97%E3%81%9F%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E8%BB%A2%E9%80%81)
- [Piping Server上でエンドツーエンド暗号化チャット - nwtgck / Ryo Ota](https://scrapbox.io/nwtgck/Piping_Server%E4%B8%8A%E3%81%A7%E3%82%A8%E3%83%B3%E3%83%89%E3%83%84%E3%83%BC%E3%82%A8%E3%83%B3%E3%83%89%E6%9A%97%E5%8F%B7%E5%8C%96%E3%83%81%E3%83%A3%E3%83%83%E3%83%88)
- [暗号化してセキュアに画面共有する - エンドーツーエンド暗号化/Piping Server経由 - nwtgck / Ryo Ota](https://scrapbox.io/nwtgck/%E6%9A%97%E5%8F%B7%E5%8C%96%E3%81%97%E3%81%A6%E3%82%BB%E3%82%AD%E3%83%A5%E3%82%A2%E3%81%AB%E7%94%BB%E9%9D%A2%E5%85%B1%E6%9C%89%E3%81%99%E3%82%8B_-_%E3%82%A8%E3%83%B3%E3%83%89%E3%83%BC%E3%83%84%E3%83%BC%E3%82%A8%E3%83%B3%E3%83%89%E6%9A%97%E5%8F%B7%E5%8C%96%2FPiping_Server%E7%B5%8C%E7%94%B1)
- [Real-time Voice Messaging over HTTP/HTTPS for Web Browser via Piping Server](https://github.com/nwtgck/piping-phone-web)
- [エンドツーエンド暗号化でお絵かき/手書きチャット - Piping Server経由 - nwtgck / Ryo Ota](https://scrapbox.io/nwtgck/%E3%82%A8%E3%83%B3%E3%83%89%E3%83%84%E3%83%BC%E3%82%A8%E3%83%B3%E3%83%89%E6%9A%97%E5%8F%B7%E5%8C%96%E3%81%A7%E3%81%8A%E7%B5%B5%E3%81%8B%E3%81%8D%2F%E6%89%8B%E6%9B%B8%E3%81%8D%E3%83%81%E3%83%A3%E3%83%83%E3%83%88_-_Piping_Server%E7%B5%8C%E7%94%B1)

Webアプリ間でエンドツーエンド暗号化してファイルを転送したり、チャットしたり、画面共有します。将来、`fetch`で任意の`ReadableStream`がアップロードできるようになると、Webアプリ間だけではなくて`curl`などのCLIとの連携が可能になるのではないかと思っています。

## おわりに
Piping Serverの実装は[前の記事](https://qiita.com/nwtgck/items/78309fc529da7776cba0)の時と比べてほとんど同じです。Piping ServerをPiping UIや途中で出てきたWebアプリを実装するために特殊な実装をしたりはしていません。あらゆるデータをストリーミングできれば、ファイル転送に限らず、チャットも通話も画面共有も実現できそうです。
「**より少ない機能で、より多くのことを実現**」出来ればいいなと思っています。

## おまけ: gifのプログレスバー表示

最後におまけです。以下を使ってプログレスバー付きのgifを生成してます。
<https://github.com/nwtgck/gif-progress>