Electron & React で ContextBridge を使って安全に DB 操作する

HTML や CSS などの Web 技術を用いてクロスプラットフォームなデスクトップアプリを開発できるフレームワーク ELECTRON

動的なウェブサイトを構築できるライブラリ React (ReactJS)

この2つを組み合わせて、データベース (CRUD) 操作を行えるアプリ作成を考えます。

[toc]

セキュリティを考える

一見すると、この課題には難しい点はないように思われます。

Renderer プロセスから ipcRenderer を読み込み、Main プロセスの ipcMain との間でプロセス間通信を実行すれば、Main プロセスを介して Renderer プロセスと DB との間でデータの受け渡しが可能です。

ところが、2020年現在、こうした設計はセキュリティ上の理由から推奨されていません。XSS で Node.js Modules にアクセスされて SSH キーを不正取得されたりしても困るといった動機は理解できますが、不便でもあります。

そこで Renderer プロセスから安全に Node.js Modules を利用するため、ほかのスクリプト実行前にあらかじめ preload で読み込むことを行います。

この preload において contextBridge を用いて関数を API 化し Renderer プロセスから利用可能にすることが最近のセキュリティ重視の設計です。

ここで問題となるのは contextBridge によって作成された API (オブジェクト) には型定義が存在しないため TypeScript のコンパイル時にエラーの原因となります。

$ npx tsc
Property 'api' does not exist on type 'Window & typeof globalThis'.

TypeScript は React を安全に使うために必要です。

ELECTRON が用いる HTML/CSS/JS にはデスクトップアプリに適した UI フレームワークが存在しないため React がそれを補います。つまり React もあったほうがいいものです。

それらのリソースを管理するのは Webpack です。

こうした一つ一つの要素技術に設定ファイルがありますので、組み合わせると面倒なことになります。




整理すると、やるべきことは次のようになります。

(1) Main プロセスにおいて preload を読み込む
(2) Preload において関数を API 化する
(3) API の型定義を行う
(4) Webpack の設定ファイルを書き換える
(5) データベースに接続して操作を行う

ここで断っておきますが、私はフロントエンドもスクリプト言語も嫌いです。当然ながら専門家ではありません。最新の公式ドキュメントには目を通していますが、より良い方法がほかにあるかもしれません。

実行環境

実行環境は以下のとおりです。

$ cat /etc/os-release 
NAME="Ubuntu"
VERSION="18.04.5 LTS (Bionic Beaver)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 18.04.5 LTS"
VERSION_ID="18.04"
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
VERSION_CODENAME=bionic
UBUNTU_CODENAME=bionic

$ node --version
v12.16.3

$ npm --version
6.14.8

$ yarn version
yarn version v1.22.5

$ cat package.json |grep dependencies -A 10
  "dependencies": {
    "eslint-loader": "^4.0.2",
    "react": "^16.13.1",
    "react-dom": "^16.13.1",
    "react-router-dom": "^5.2.0",
    "react-scripts": "^3.4.1",
    "sqlite3": "^5.0.0",
    "typeorm": "^0.2.26",
    "typescript-fsa": "^3.0.0",
    "typescript-fsa-reducers": "^1.2.2"
  },

$ cat package.json |grep devDependencies -A 21
  "devDependencies": {
    "@types/react": "^16.9.49",
    "@types/react-dom": "^16.9.8",
    "@types/react-router": "^5.1.8",
    "@types/sqlite3": "^3.1.6",
    "@typescript-eslint/eslint-plugin": "^4.1.0",
    "@typescript-eslint/parser": "^4.1.0",
    "electron": "^10.1.1",
    "electron-rebuild": "^2.0.3",
    "eslint": "^7.8.1",
    "eslint-config-google": "^0.14.0",
    "eslint-config-prettier": "^6.11.0",
    "eslint-plugin-prettier": "^3.1.4",
    "eslint-plugin-react": "^7.20.6",
    "html-webpack-plugin": "^4.4.1",
    "prettier": "^2.1.1",
    "react-devtools": "^4.8.2",
    "ts-loader": "^8.0.3",
    "typescript": "^4.0.2",
    "webpack": "^4.44.1",
    "webpack-cli": "^3.3.12"
  }

TypeORM をインストールはしていますが後述の理由から使用しません。また機能要件に対して必要性を感じないので Redux も使用しません。

Main プロセスにおいて preload を読み込む

ELECTRON の main プロセスの内容を記述したファイルを main.ts とすると、その内容は次のようになるはずです。

import { app, BrowserWindow } from 'electron';
import path from 'path';

const createWindow = () => {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      allowRunningInsecureContent: false,
      contextIsolation: true,
      enableRemoteModule: false,
      nodeIntegration: false,
      nodeIntegrationInWorker: false,
      preload: path.join(app.getAppPath(), 'preload.js'), // これを追記
    },
  });
  win.loadFile(path.join(app.getAppPath(), 'index.html'));

  win.on('closed', () => {
    win = null;
  });
};

ここでドキュメントに従い、BrowserWindow の中に preload を設定します。

ファイルパスは環境に応じて適宜変更してください。

Preload において関数を API 化する

Main プロセスから preload を読み込むよう設定したあとは、main プロセスと renderer プロセスとの間で通信を行う準備を行います。

ここで contextBridge を用いて関数を API 化することが肝要です。

import { ipcRenderer, contextBridge } from 'electron';

contextBridge.exposeInMainWorld(
  'api', {
    electronIpcInvoke: (channel: string, ...arg: any) => {
      ipcRenderer.invoke(channel, arg);
    },
  }
);

先述のように contextBridge によって作成された API (オブジェクト) には型定義が存在しないため TypeScript のコンパイル時にエラーの原因となります。

API の型定義を行う

そこで正統な対処法として、型定義ファイルを作成して tsconfig に定義ファイルを読み込ませることがよく行われます。

$ mkdir -p ./src/\@types
$ vim ./src/@types/global.d.ts

個人的に確認はしていませんが、原則的にはこれで問題なく動作すると思われます。

この代わりに、私は API に対して interface を作成し、実行時に import することを行いました。

export default interface Api{
  electronIpcInvoke:(channel: string, ...arg: any) => Promise ;
}
declare global {
  interface Window {
    api: Api;
  }
}

これで main プロセスと renderer プロセスとの間でデータの受け渡しを行えるようになります。

Webpack の設定ファイルを書き換える

型定義を与えてコンパイル(トランスパイル)できるようになっても、肝心の preload.js ファイル自体が実行ファイルとして読み込まれなければ意味がありません。

そのために次は webpack.config.js の設定を書き換えて preload を読み込ませます。

module: {
     entry: {
        index: './src/index.tsx',
        main: './src/main.ts',
        preload:'./src/preload.ts'
     },
     output: {
        path: path.join(__dirname, 'dist'),
        filename: '[name].js',
     },
}

具体的には entry と output の項目に preload を書き加えるだけで大丈夫です。




データベースに接続して操作を行う

ようやくデータベースを利用する準備が整いましたので、main プロセスからデータベースに接続する関数を記述します。

import { app, BrowserWindow, ipcMain, IpcMainEvent } from 'electron';
import path from 'path';
import * as sqlite3 from 'sqlite3';

const createWindow = () => {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      allowRunningInsecureContent: false,
      contextIsolation: true,
      enableRemoteModule: false,
      nodeIntegration: false,
      nodeIntegrationInWorker: false,
      preload: path.join(app.getAppPath(), 'preload.js'),
    },
  });
  win.loadFile(path.join(app.getAppPath(), 'index.html'));

  win.on('closed', () => {
    win = null;
  });
};

// DB操作を追記
ipcMain.handle('connectTest', async () =>{
  console.log('-- connection starts --');
  const db = new sqlite3.Database('database.sqlite3');
  db.serialize(function() {
    db.run("CREATE TABLE lorem (info TEXT)");
    let stmt = db.prepare("INSERT INTO lorem VALUES (?)");
    for (let i = 0; i < 10; i++) {
        stmt.run("Ipsum " + i);
    }
    stmt.finalize();
   
    db.each("SELECT rowid AS id, info FROM lorem", function(err, row) {
        console.log(row.id + ": " + row.info);
    });
  });
  db.close();
  console.log('-- It worked --');
});

これを IPC 通信を用いて renderer プロセスから呼び出してやれば良いわけです。

ここに来てついに React の出番となります。

preload にて定義した API のelectronIpcInvoke を呼び出します。

import React from 'react';
import ReactDOM from 'react-dom';
import './config/api.interface';

function btnClick(e):void {
  window.api.electronIpcInvoke('connectTest','');
}

const container = document.getElementById('contents');
ReactDOM.render(
  
, container);

これで Connect ボタンをクリックすると IPC 通信で main プロセスの connectTest を呼び出して、データベースに接続・操作を行います。

この状態でビルド、実行してボタンをクリックした際に以下のように表示されれば成功です。

$ npm start
> electron ./dist/main.js

-- connection starts --
-- It worked --
1: Ipsum 0
2: Ipsum 1
3: Ipsum 2
4: Ipsum 3
5: Ipsum 4
6: Ipsum 5
7: Ipsum 6
8: Ipsum 7
9: Ipsum 8
10: Ipsum 9

TypeORM を使えない

以下は余談です。

TypeORM (version 0.2.26) にて ormconfig.json を作成せずに createConnection に直接的に引数を渡そうとするとエラーになります。

より具体的には以下のようなことをやっています。

import {createConnection} from "typeorm";
import options from './config/config/ormconfig';

const connection = createConnection(options);
import { ConnectionOptions, DatabaseType } from "typeorm";
import { Customers } from "../db/entity/customers.schema";

const sqliteDB : DatabaseType = 'sqlite';
export const options: ConnectionOptions = {
  type: sqliteDB,
  database: 'database.sqlite',
  synchronize: true,
  logging: false,
  entities: [Customers,],
};

明らかに現状では開発中のライブラリですし、どうしても必要なものではありませんので私は使用することを諦めました。

ちなみに下記のようなエラーはコンストラクタを作成して各カラムの変数を初期化すると消えます。


TS2564: Property 'name' has no initializer and is not definitely assigned in the constructor.


エンティティを定義されているファイルに constructor を追加してやれば問題解決です。

import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";

@Entity()
export class Customers {
  @PrimaryGeneratedColumn()
  id?: string;

  @Column()
  name:string;

  @Column()
  zip:string;
  
  @Column()
  address:string;

  @Column()
  countrycode:number;
  
  @Column()
  phone:number;

  constructor(name:string, zip:string, address:string, countrycode:number, phone:number){
    this.name=name;
    this.zip=zip;
    this.address=address;
    this.countrycode=countrycode;
    this.phone=phone;
  }
}

また次のようなエラーは tsconfig.json の設定により表示を消すことができます。


TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.


お好きなエディタで設定ファイルを開いて該当箇所のコメントアウトを削除してください。

$ vim tsconfig.json

/* Experimental Options */
    "experimentalDecorators": true,        /* Enables experimental support for ES7 decorators. */

こうしたマイナーなエラーは簡単に対処できますが、データベースに接続できないことにはどうしようもありません。

改善されることを待つか、使用することを諦めるほかにありません。私はもう諦めてしまいました。

サイクルコンピュータを自作する #2 – みちびき対応 GPS と市販のサイコンの比較実験

先の記事で延べた日本の準天頂衛星システム(QZSS)『みちびき』とそれに対応した GPS モジュール GYSFDMAXB を使用すると、衛星電波から現在時刻や位置情報や高度を手軽に取得することが可能になります。

これによって得られた情報を無線で送信すれば盗難車の追跡にも利用できますし、受信データを記録媒体に書き残し、ケイデンスセンサ等の補助的なデータを付与して FIT ファイル形式にエンコーディングしてやれば、サイクルコンピュータ(サイコン)替わりにライド情報を記録する装置を自作することも難しくはありません。

もちろん、最近のサイコンは GPS データを受信できるものも少なくないので、そちらを購入すれば間違いないことは確かです。

わざわざ時間と労力をかけてサイコンを自作する意味はありませんが、たとえばブルベで一度に 300km 以上を走行したり、九州一周を一筆書きしたい場合などには、それなりに実用性があるかもしれません。

なにしろ自作なので、いくらでもバッテリ容量を増やせますし、ディスプレイなど不要だと思えば付けなくても構わないわけです。




GPS モジュール単体でも現在時刻や位置座標に加えて高度と移動速度を取得することが可能なので、サイコンと同様のデータを記録したければ、これに温度センサやケイデンスセンサなどを付け足せば、市販品と同じ記録装置ができるはずです。

それでは肝心の GPS モジュールの精度は、GPS 搭載サイコンと比較してどのようなものなのでしょうか。気になったので、手元にある GPS 端末と一緒に計測にいってきました。

比較対象は GARMIN の EDGE 520 (サイクルコンピュータ, 2015年発売)と OREGON 600 (ハンドヘルドデバイス, 2013年発売) そして ForeAthlete230J (ランニングウォッチ, 2016年発売) の3点です。

4つのデバイスを同じカバンに入れて御苑や皇居の周辺を歩いてきたのですが、なぜか最初は ForeAthlete230J の計測ができておらず、途中からは仮組みだけで溶接していなかった GPS モジュールが断線する(ジャンパワイヤが抜ける)というアクシデントが発生したので、結局、4つまとめての計測は行なえませんでした。

しかし、計測できた部分だけでも見比べてみると、それぞれの性質の違いがよく見えてきます。GPSモジュールの位置情報ログが黒、EDGE 520 のそれが赤、ForeAthelete230J 青、 OREGON 600 紫です。

実験前は『みちびき』対応の GPS モジュールが正確さで圧倒するものかと思えましたが、GARMIN が意外にも検討していますね。それでも高架下やトンネルに弱い点は、この結果だけで分かってしまいます。

GPS モジュールの『みちびき』対応の成果は交差点や曲がり角に見て取れます。一方でモジュールは振れ幅も大きく、実際に通った場所からみて反対車線を通行したことになっていたり、まったく無関係な位置情報を拾っていたりもします。この辺り、GARMIN は受信したデータを内部でフィルタリングしているのかもしれません。

いざサイコンを作ってみようとなると検討すべき項目がいろいろ出てきますね。


計測環境をより詳しく知りたい人は以下を見てください。

計測日時 2020年6月25日 17:06 – 18:26
天候 曇り/雨
気温 25℃
湿度 78%
風速 3m/s from SSE
出力 GYSFDMAXB TXT/CSV
EDGE 520 FIT
FOREATHLETE 230J FIT
OREGON 600 GPX

GPS モジュール GYSFDMAXB は単体ではデータを記録することはできませんので、マイコンボードを経由して microSD カードにGPS受信ログを記録させています。

ここではマイコンボードに Arduino (IDE ver. 1.8.13), microSDカードスロットに AE-MICRO-SD-DIP, 記録媒体に SanDisk Ultra PLUS SDHC 16GB を使用しています。

MicroSDカードを FAT Format でフォーマットすると AE-MICRO-SD-DIP を用いてマイコンから読み書きできるようになりますので、Arduino IDE の Files > Examples > SD > ReadWrite からサンプルスケッチを読み込み、ファイル書き込み部分に GPS モジュールの出力を入れると簡単に実装できます。

今回、使用したのは以下のスケッチです。

#include 
#include 

#include 
#include 

SoftwareSerial mySerial(9, 10); // RX, TX
TinyGPSPlus gps;

void setup() {
  Serial.begin(57600);
  mySerial.begin(9600);  // Open serial communications and wait for port to open:
  while (!mySerial) {
    Serial.print("waiting for serial port to connect");
  }
  Serial.print("Initializing SD card...");
  if (!SD.begin(4)) {
    Serial.println("initialization failed!");
    while (1);
  }
  Serial.println("initialization done.");
}

void loop() {
  if (mySerial.available()) {
    gps.encode(mySerial.read());
    if (gps.location.isUpdated())
    {
      // open the file. note that only one file can be open at a time,
      // so you have to close this one before opening another.
      File myFile = SD.open("test.txt", FILE_WRITE);
      // if the file opened okay, write to it:
      if (myFile) {
        // timestamp
        myFile.print(gps.date.year());
        myFile.print(',');
        myFile.print(gps.date.month());
        myFile.print(',');
        myFile.print(gps.date.day());
        myFile.print(',');
        myFile.print(gps.time.value());
        myFile.print(',');
        // location
        myFile.print(gps.location.lat(), 6);
        myFile.print(',');
        myFile.print(gps.location.lng(), 6);
        myFile.print(',');
        myFile.print(gps.altitude.meters(), 6);
        myFile.print(',');
        // ground speed
        myFile.print(gps.speed.mps());//in meters per second (double)
        myFile.print(',');
        myFile.print(gps.speed.mph());//in miles per hour (double)
        myFile.print(',');
        myFile.print(gps.speed.kmph());// in kilometers per hour (double)
        myFile.print(',');
        // satellite 
        myFile.print(gps.satellites.value()); // Number of satellites in use (u32)

        myFile.println();
        myFile.close();     // close the file:
      } else {
        // if the file didn't open, print an error:
        Serial.println("error opening test.txt");
      }
    }
  }
}

スケッチをマイコンボードに書き込みましたら、ボードとモジュールをワイヤで接続していきます。GPS モジュール GYSFDMAXB はスケッチで指定した SS に RXD, TXD を接続して 5V 出力と GND は素直にそれぞれを接続すれば問題ありません。

SD カードスロットの方は、ピンがたくさんあって少しだけ複雑です。SD カードの裏側の端子(金色の窓の部分)は、たしか1つづつ別の役割があって、それがこのピンの数だけ・・・ 何だったかなと思っていたら、良い記事がありました。


Arduinoでパーツやセンサを使ってみよう~SDカード編(その1) | Device Plus – デバプラ
https://deviceplus.jp/hobby/entry021/


この記事通りにやっておけば、間違いがなさそうです。

ただ1点だけ AE-MICRO-SD-DIP の取扱説明書に記載されている DAT1 と DAT2 はそれぞれ data line 1 と 2 で「使いません」というか、予約されていて使えませんというほうが正しかった気がします(要確認)。

AE-M-SD-DIP ARDUINO UNO Pin
#2 CD/DAT3 chip select D4 #4
#3 CMD/DI host command/data MOSI #11
#4 VDD supply voltage 3V3 3.3V
#5 CLK clock SCL #13
#6 VSS supply ground GND GND
#7 DAT0 host data/status MISO #12

とりあえずは使えることを優先して、配線して通電すると下のようなファイルが microSD カードに作成されます。上のスケッチで記述した通り、年、月、日、時刻、緯度、軽度、高度、移動速度、捕捉衛星数が書き込まれている事が分かります。

2020,6,25,8142100,35.688156,139.704177,19.200000,2.64,5.90,9.50,8
2020,6,25,8142200,35.688156,139.704162,19.200000,2.64,5.90,9.50,7
2020,6,25,8142200,35.688156,139.704162,19.200000,1.48,3.31,5.33,7
2020,6,25,8142300,35.688159,139.704162,19.100000,1.48,3.31,5.33,8
2020,6,25,8142300,35.688159,139.704162,19.100000,1.01,2.26,3.63,8
2020,6,25,8142400,35.688171,139.704147,18.899999,1.01,2.26,3.63,8
2020,6,25,8142400,35.688171,139.704147,18.899999,1.36,3.05,4.91,8
2020,6,25,8142500,35.688194,139.704086,19.299999,1.36,3.05,4.91,8
2020,6,25,8142500,35.688194,139.704086,19.299999,2.15,4.80,7.72,8
2020,6,25,8142600,35.688209,139.704040,19.399999,2.15,4.80,7.72,8
2020,6,25,8142600,35.688209,139.704040,19.399999,2.54,5.67,9.13,8
2020,6,25,8142700,35.688209,139.703994,19.299999,2.54,5.67,9.13,7

OREGON 600 が出力する GPX ファイルと比較すると、こんな風になります。GPX ファイルの中身がおよそ 1 秒おきに記録されているのと比較すると、GPS モジュール GYSFDMAXB のほうは重複なしに同時刻に2回記録されていることが少し気になります。

ちなみに OREGON 600 が出力する GPX ファイルは XML に他ならないので、XML parser を使うとカンマ区切りのテキストファイルに容易に変換できます。

const parser = require('xml2json');
const fs = require('fs');
const xml = fs.readFileSync(process.argv[2], 'utf-8');
const json = JSON.parse(parser.toJson(xml));
for (const trk of json.gpx.trk.trkseg.trkpt) console.log(trk.time + ',' + trk.lat + ',' + trk.lon + ',' + trk.ele);

ここから位置情報を地図上に描画するには、下の記事を参考にされてください。


Garmin / Strava の走行記録をまとめて表示する


GARMIN Edge や Forerunner/ForeAthlete の FIT ファイルを地図上に表示するには以下の記事を参考にされると簡単です。


アプリを使ってライド記録を地図上に描こう


ここまでで今回は GPS 位置座標を比較しましたが、GPS受信データにはまだ他に高度や移動速度などの項目が含まれています。新宿駅東口の海抜がおよそ 37m 程度、新宿御苑周辺の海抜が 33.5m 程度なので、一見すると高度には大きな誤差はなさそうに見えますが、詳しくは調べてみないと分かりませんね。

それからサイコンとして使用するからには晴天の日にも自転車での移動速度の計測してみたいところです。いまのところデータセットが足りていないので、比較検証のためには増やしたい一方、計測する側からすると負担が大きいので端末の数を減らしたいところです。

もう少しデータセット増やしたいんですけどねえ。

アプリを使ってライド記録を地図上に描こう

いままでに走ったことのある場所を可視化したい。他の人と自分の走行記録を見比べたい。知らない道を手早く見つけたい。

スポーツ自転車に乗ってサイクルコンピュータで走行記録をとっている人であれば、現在までの走行記録をまとめて表示してみたくなることがあるものです。およそ半年前に Garmin / Strava の走行記録をまとめて表示するという記事を書いたところ、今でも毎日、それなりのアクセス数がありますので同じことを考えている人も少なくないのではないかと推測されます。

実際に表示してみると、もう走り尽くしたと思っていた地域にも行ったことがない場所が、そこかしこに残っていたりと、新しい発見につながるものです。

しかし、上の過去記事に取り上げた方法では、多少の事前知識と実行環境の準備が必要となりますので、誰でも直ぐに実現できるというわけではありません。

そこで今回はデスクトップアプリを使用して、1つ以上の FIT ファイルから位置情報を抜き出して、ウェブブラウザ上で地図に表示することを行います。

手っ取り早く使い方だけを知りたい人は、以下のリンクを見てください。


FITファイルから位置情報を取り出して地図上に表示する方法





そんなものがあるなら、どうして最初に紹介しなかったのかと言うと、無かったので(少なくとも私が探した限りでは見つからなかったので)自分で作ったからです。

ただし、要素技術に用いた Electron に触れることも今回が初めて、シングルページアプリケーション (SPA) に挑戦することも今回が初めて、そもそもフロントエンド自体の経験が全くありませんので、出来栄えについては保証できません。

当初は「週末の2日あれば完成するかな」と軽く考えていたところ、実際には見積もりの1.5倍ぐらいの作業時間が完成までに必要となりました。

とにかく未経験なので SPA のファイル構成や暗黙の規則などの勝手が分からないですし、標準エラー出力の表示からモジュールの読み込み、パッケージビルドまで躓き続けて、ドキュメントも部分的にしか目を通せていないので、一応は動くものを作成した現在においても完全に Electron を理解できたとは到底言えません。

と言うか、1年前の情報でも古くて役に立たなくなっていたりするのに、Electron どころか webpack に elecron-builder にと調べることがありすぎて沼が深すぎます。 JVM も Linux もインストールされていない環境でもプログラムを動かせるぞと思って飛び付いたことを後悔するレベルです。

それでは何故、こんな記事やコードやプログラムを公開しているのかと言えば、恥ずかしくても練習しないと上達しないからです。

とくに人が直接的に操作することを前提としたプログラムは、実際に触って動かしてみないと評価できません。そのために開発中においても評価目的でバイナリを公開しています。

現状は以下のとおりです。


読み込めるのは FIT 形式のファイルのみです(GPXファイルへの対応は未定)
FITファイルの保存形式はSI単位系のみ対応しています
ポリライン描画に使用できるのは赤のみです(何を基準に色を変えるかによって様々な表現ができるので実装を保留中)
地図タイルは OSM のみです
一度に読み込めるファイル容量はお使いの環境に依存します

緩募:アイコン、 Wahoo および CATEYE のアクティビティファイル、新機能のアイデア

私の会社ではありませんけれども、同業の知り合いの会社も3月からレイオフが実施されていていて、業績が悪いのにめちゃくちゃ忙しいので、通常にもまして自由時間が少ない(作業の進展が遅い)です。


具体的な使い方はこちらになります


パナレーサー(Panaracer) パナレーサー 日本製チューブ2本入 [W/O700x23~26C] 仏式バルブ 0TW700-25F-NP-2 仏式バルブ34mm