おぼえがき

ここには技術に関することを書いていきます。デザイン関連はmediumに書いてます。

NotionのデスクトップアプリをオリジナルのJavaScriptで改造する (2021年4月版)

2年ぶりくらいにやってみたら少しだけやりかたが変わっていた(簡単になっていた)のでまとめ直し。

MacOS向けですが、Windowsとかでもたぶん同じです。

①Notionアプリの内容を開く

ApplicationフォルダにあるNotionアプリの内容を開きます。

f:id:taku_oka:20210404170821p:plain
ここからAppの中身を開く

②preload.jsを開く

場所は以下のスクショのとおり。

f:id:taku_oka:20210404170823p:plain
ここにあるファイル

③inPageSearchのstopのところにからオリジナルスクリプトを呼べるようにする

以前(1~2年前)と変わらずここに仕込むとページ遷移ごとに呼ばれていい感じにユーザースクリプトを注入できます。

f:id:taku_oka:20210404170825p:plain
ここに仕込むといい感じ

④好きなところにオリジナルスクリプトを書く

例えばタイムラインのアイテムに"🔥"マークがあったら赤くする」としたらこんなコード。

これコードなぐり書きでめっちゃ汚いですけど、初心者のために貼ります。

上級者はもうここまでで十分だと思うのでコード読まないでください。

ていうか5分で書いたし自分用だしめっちゃ適当です。本気出したらもっとエレガントになります。

// =====================================================================================
//      ↓ MY NOTION HACK ↓
// =====================================================================================
var myNotionHack_onPageChange = function(){};

var TITLE_DIV_HACK_CLASS = "TITLE_DIV_TO_HACK";

var INJECTION_STYLE_TAG = (function() {/*
<style id="myHackStyle"> 
.TL_ITEM_LEVEL_FIRE {
    background: red!important;
}
</style>
*/}).toString().replace(/(\n)/g, '').split('*')[1];

// タイムラインのハック
function appendOriginalClassesToTimelineItems() {
    console.log("appendOriginalClassesToTimelineItems");

    var getTimelines = function () {
        return document.querySelectorAll(".notion-timeline-view");
    }

    var getRows = function () {
        return document.querySelectorAll(".notion-timeline-item-row");
    }

    var appendLevelClassToItem = function (rowDiv) {
        var itemDiv = rowDiv.querySelector(".notion-timeline-item");
        var propDivs = rowDiv.querySelectorAll(".notion-timeline-item-properties > div > div");
        isAppendded = false;
        propDivs.forEach(function(propDiv) {
            if (propDiv.innerText.indexOf("🔥") >= 0) {
                if(!itemDiv.className || itemDiv.className.indexOf("TL_ITEM_LEVEL_FIRE") == -1) {
                    itemDiv.classList.add("TL_ITEM_LEVEL_FIRE");
                    isAppendded = true;
                }
            }
        });
        return isAppendded;
    }

    // MAIN
    var timelines = getTimelines();
    if (timelines.length == 0) {
        console.log("timeline is not found")
        return false;
    }

    var isAppendded = false;
    var rows = getRows();
    rows.forEach(function(row) {
        if (appendLevelClassToItem(row)) {
            isAppendded = true;
        }
    }); 
    return isAppendded;
}

(function MY_NOTION_HACK_SCRIPT() {
    function us_injectStyleTag() {
        if (document.querySelector("#myHackStyle") == null) {
            document.body.insertAdjacentHTML('afterbegin', INJECTION_STYLE_TAG);
            return true;
        }
        return false;
    }
    
    function polling(times, action) {
        for (var i=0; i<times.length; i++) {
            setTimeout(action, times[i]);
        }
    }

    myNotionHack_onPageChange = function() {
        var isCompleteStyle = false;
        var isCompleteTimelineHack = false;
        polling([100, 200, 300, 350, 500, 1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10000, 150000], function() {
            if (!isCompleteStyle) { isCompleteStyle = us_injectStyleTag(); }
            if (!isCompleteTimelineHack) { isCompleteTimelineHack = appendOriginalClassesToTimelineItems(); }
        });
    }
})();
// =====================================================================================
//      ↑ MY NOTION HACK ↑
// =====================================================================================

④結果

例えば結果はこんな感じ。 デバッグツールはChromeとかと同様にアプリ内で cmd + opt + i とかで開けます。

f:id:taku_oka:20210404171651p:plain

Notionのカレンダーを見やすくハックする

先日、このような記事を書きましたが、これを生かして新たにNotionの個人的な課題を解決してみました。

takuyaokamoto.hateblo.jp

個人的に困っていたのは、 タスクボードのカレンダーViewで強弱がつけづらく、すべての予定が均等の強さに見えてしまうことでした。

小さなタスクも重たいタスクと同様に表示されてしまうので、ぱっとみですごく焦ります。

以前はRealtimeBoardで1週間の予定を管理していたためそのあたりの表現は自由自在だったのですが、Notionに切り替えてから毎日ソワソワしてカレンダーViewを見つめてしまうようになり、とても気になりました。

f:id:taku_oka:20190128221251p:plain
デフォルトのスタイル

そこで、こんなふうにLevelプロパティを追加し、レベルを選択することでそのタスクの重さを表現できないかと考えました。

f:id:taku_oka:20190128221150p:plain
Levelプロパティを追加

Notionはほとんどの要素がdivでクラス名も無いため少し大変でしたが、 先日の記事と同じ手法を使ってこんなふうに強弱をつけるユーザースクリプトを実装できました。

f:id:taku_oka:20190128221224p:plain
レベルごとに強弱をつける

ハック後はだいぶ見やすくなったように思います。

f:id:taku_oka:20190128221313p:plain
ハック後のスタイル

NotionのDesktopアプリのUIをカスタマイズするハック

最近Notionというツールを[ノートアプリ + タスク管理アプリ]として乗り換えてみたところ、めちゃくちゃ便利で毎日使い倒しています。

ただNotionってタイトルと絵文字がめっちゃでかく表示されておしゃれなのですが、職場だとそれがなんだか恥ずかしかったので小さくしたくなりましたw

f:id:taku_oka:20190128223223j:plain
UIの文字がでかいのでハズい

そこで、Notionのデスクトップ版にカスタムのJavaScriptを当てる方法を模索してみました。

Electronを触るのは初めてでしたが、結果的にはユーザースクリプトを流し込み、常に自分好みのUIに調節することに成功しました!

Macでのやり方を説明します。

1. NotionアプリからDevToolを開き、preload.jsの場所を確認する

Electronが提供しているwebviewタグというものがあり、

その中にWeb版のNotionを表示しているようです。

属性要素をよく見ると、preload.jsというものが読み込まれています。

(おそらくこれもElectronではデフォルトの方法でしょうか)

f:id:taku_oka:20190119235334j:plain
electronのwebview

僕の場合は

file:///Applications/Notion.app/Contents/Resources/app.asar/renderer/preload.js

でした。

2. preload.jsを編集するため、app.asarファイルを解凍する

app.asarファイルを解凍するためには、はasarというコマンドラインツールのインストールをしておきます。

npm install -g asar

以下のように解凍できます。

asar e app.asar myApp

3. preload.jsにユーザースクリプトを追記する

そこにJavaScriptを書けばDOMにアクセス可能なので、UIの変更など基本的になんでもできます。

Notionのページ遷移イベントのハック

Notionの場合はページ遷移時のイベントがわからなかったので、以下のようなスクリプトでElectronのipcメッセージを確認したところ、それに近いイベントが見つかったので、それを利用することにしました。

webview.addEventListener('ipc-message', function(event) {
    console.log(event);
});

4. preload.jsを圧縮し、置き換える

asar pack myApp app.asar

5. アプリにリロードをかけて確認

アプリを⌘Rなどで更新すれば変更が反映されているはずです。

※ WebviewのDevToolを開く方法

以下のようなコードをNotionデスクトップアプリのDevToolで実行することでWebviewのDevToolを開くことができます。

document.querySelector('webview').openDevTools()

ハック後のUI

f:id:taku_oka:20190128223305p:plain
変更前
f:id:taku_oka:20190128223321p:plain
ハック後のUI

Embedded Frameworkの導入の検討 / firefox-iosの例

f:id:taku_oka:20170821020210p:plain

個人的なアプリでは導入してみていますが、途中から入った現在のプロジェクトでも導入を検討しているので、色々と整理してみました。

Embedded Frameworkを取り入れる利点

ビルドパフォーマンスの改善

EmbeddedFrameworkを導入することで差分コンパイルが効きやすくなるため、ビルドの時間を減らすことに大きく貢献します。

そのためビルド時間短縮の有効な手段の1つとしてよく取り上げられています。

本格的なiOS開発では、ビルドパフォーマンスを意識せずに開発を続けていくと大抵の場合フルビルドに8分~15分くらいかかるようになっていきます。しかもメソッドを1つ追加するだけでも全体をビルドし直しになったりします。

Swiftはコードが増えてくると凄く生産性が悪くなるのでビルド時間の短縮化は開発者の間でよく取り扱われるテーマの1つとなっています。

ウィジェットなどとの間で機能を共通化できる

iOSアプリ本体に加えて、ウィジェットやシェア拡張などのAppExtensionを開発する場合やiPad向けアプリやAppleTV向けアプリやWatch向けアプリを開発する際、ビルドターゲットはアプリとは別になります。

ターゲットの切り分けをしていない場合、 TodayウィジェットからAPIを叩く必要があるときは、アプリのコードを使用することができず、結果、二重にAPIへのリクエスト部分を実装することになってしまいますが、 機能がEmedded Frameworkとして切り離されていればアプリとウィジェット間で共通の機能を使うことが出来ます。

コードがキレイになる

依存がはっきりとわかれて、名前空間も別れるので簡潔なコードが書きやすくなります。

ターゲットを分けずにアプリ開発している場合はアクセス修飾子のinternal / public の違いがなくなりますが、 フレームワーク内ではinternal/publicが意味を持つようになり、より安全に分かりやすく開発していくことが可能です。 ターゲット内でのみ使えるinternalなクラスを作ったり、外部に公開するクラスやメソッドを限定的にしてモジュールを使いやすく分かりやすいインターフェイスにしていけます。

Embedded Frameworkの作り方

参考になりそうな記事を貼っておきます。

Embedded Frameworkを導入してXcodeのビルドパフォーマンスをあげる - VASILY DEVELOPERS BLOG

Embedded Frameworkの分け方の例

以下のように層ごとで分けることが多いかと思います。

メインターゲット

Embed Framework

  • Util (全体で使う汎用的な機能やExtensionなど)
  • APIClient
  • Model
  • in-App-Purchase機能

mozilla-mobile/firefox-ios の例

mozilla-mobile/firefox-ios

embed frameworkを取り入れている例として firefox-iosオープンソースプロジェクトをざっくり観察してみました。

ターゲットのわけ方

f:id:taku_oka:20170821020346p:plain:w300

メインターゲット

  • Client(iOSアプリ)
  • ShareTo(ShareExtension)
  • Today(TodayExtension)
  • NotificationService(NotificationExtension)
  • SendTo(ActionExtension)
  • ViewLater(ActionExtension)

Embed Frameworks

  • Shared: 汎用的なextensionやUtil系クラス
  • Storage: ブックマークなどの永続化層?
  • Account: アカウント管理?
  • Sync: ブックマークなどの同期?
  • ReadingList: リーディングリストの管理?
  • Telemetry: 効果測定?

Model,APIClient…というふうに層で分けるのもいいですが、firefoxのように機能ごとに分けるのもよさそうですね。

ライブラリの管理をどうしているのか

f:id:taku_oka:20170821020408p:plain

Carthageで取得してきたframeworkたちが一番上の階層のFrameworksフォルダにまとめられており、各ターゲットがおのおの必要なフレームワークを取り入れていました。

具体的には各ターゲットの”Linked Framewoks and Libraries”, ”Linked Binary with Libraries” に使用するframeworkを追加して、使用しているファイル内でimport文を追加しています。

f:id:taku_oka:20170821020430p:plain

EmbedFrameworkを使いすぎると起動が遅くなる件について

EmbedFrameworkを使いすぎると起動が遅くなるそうです。 (参考資料: More Swift App Startup Time)

つまりEmbedFrameworkを使用してビルド時間を短縮することとアプリの起動時間が伸びるのはトレードオフとのこと。 (参考資料: iOSプロジェクトのBuildを高速化する)

しかし実際 firefoxのアプリの起動時間を体感してみたところそこまで起動の遅さが感じられなくて安心しました。 むしろ平均以上の速さのように感じました。

firefoxのEmbeded Frameworkの数

  • 外部framework 23個
  • 内部framework 6個

このframework数でiPhone6sで250msほどで起動していました。

firefoxのEmbeded Frameworkの数は少なくもなく多くもなく通常のアプリ開発はこのくらいの数になると思います。

これで充分に起動時間が速いので、過剰な数のEmbeded Frameworkを使いさえしなければ起動時間の伸びについては特に問題になることはなさそうだと思いました。

まとめ

Embeded Frameworkは利点が多く、ウィジェットの開発を視野に入れていたりビルド時間を肥大化させたくない場合は早めに導入できると良さそうだと思いました。

firefox-iosは Embed Frameworkを活用している例としては貴重で参考になりました。 大手のプロジェクトでEmbed Frameworkを活用した例があると安心感があります。 僕の会社でも最近のiOSプロジェクトはEmbed Frameworkを導入いる例がいくつかあるので参考にしてみたいと思いました。

導入時期の検討

Embed Frameworkの導入はアプリ開発が進むほどにコストが高まっていきます。

僕のアプリの場合はリリースが近くなってしまったので、今導入するとデグレの危険性があるためリリース直後くらいに行えたら良いなと思っています。

JavaScriptで定期的にWi-FiをOFF➜ONする

僕は普段はSwiftを書いているのですがインスタントな雑スクリプトJavaScriptで書きます。

カフェでドヤマックしてたのですがWifiの調子が悪くて定期的にon/offしていたので、それを自動化したいと思い雑なnodeJSを書きなぐりました。

Wifiを「切」にして3秒後に「入」にする`を60秒ごとに行うスクリプトです。 Shell Commandをタイマー実行しているだけですが。

toggleWifi.js
const exec = require('child_process').exec;

var intervalSec = 60;

function main() {
    console.log("start to toggle wifi");
    setInterval(function() {
        toggleWifi();
    }, 1000 * intervalSec)
    toggleWifi();   
}

function toggleWifi() {
    console.log("toggle...");
    changeWifiPower(false);
    setTimeout(function() {
        changeWifiPower(true);
    }, 1000 * 3);
}

function changeWifiPower(isOn) {
    var nextState = isOn ? "on" : "off";
    var command = "networksetup -setairportpower en0 " + nextState;
    doShellCommand(command);
    console.log("wifi turn " + nextState);
}

function doShellCommand(command) {
    exec(command, (err, stdout, stderr) => {
      if (err) {
        console.log(err);
      }
      console.log(stdout);
    });
}

main();

使い方

$ node toggleWifi.js

WKWebViewのリダイレクト時の挙動

WKWebViewのリダイレクト周りの挙動が分からなかったので調べてみました。 (iOS10 / iOS9) (Swift3.2)

リダイレクトはWKNavigationDelegateで検知するしかなさそう

自分の実験の中ではWKWebViewではリダイレクトのステータスコード(300系)を観測できなかった。 どうやらWKWebViewが内部的にリダイレクトは処理してしまっているようだったので、 WKNavigationDelegateによってそのリダイレクトによる遷移を検知した。

主要なWKNavigationDelegateのメソッド

func webView(WKWebView, decidePolicyFor: WKNavigationAction, decisionHandler: (WKNavigationActionPolicy) -> Void)

Decides whether to allow or cancel a navigation.

  • リクエストを開始するときに呼ばれる。そのまま遷移をさせるか、キャンセルするかを決める。
  • リダイレクトが起これば連続してよばれる。
  • navigationTypeを見るとクリックよる遷移か、リダイレクトによる遷移かがわかる。

func webView(WKWebView, decidePolicyFor: WKNavigationResponse, decisionHandler: (WKNavigationResponsePolicy) -> Void)

Decides whether to allow or cancel a navigation after its response is known.

  • レスポンスヘッダが返ってきたときに呼ばれる。そのまま遷移をさせるか、キャンセルするかを決める。
  • このあとにリダイレクトでまたnavigationActionが呼ばれることもあるので、ここで終わりとは限らない。

func webView(WKWebView, didCommit: WKNavigation!)

Called when the web view begins to receive web content.

  • リダイレクトが終わってコンテンツのロードが始まった時に呼ばれる。

リダイレクトの流れ

リダイレクトが終わるまで

webView(WKWebView, decidePolicyFor: WKNavigationAction, decisionHandler: (WKNavigationActionPolicy) -> Void)

webView(WKWebView, decidePolicyFor: WKNavigationResponse, decisionHandler: (WKNavigationResponsePolicy) -> Void)

連続で呼ばれ続け、

リダイレクトが終わってコンテンツのロードが始まると

webView(WKWebView, didCommit: WKNavigation!)が呼ばれる。

気をつけること

  • decidePolicyFor: WKNavigationResponseが呼ばれても終わりとは限らない。さらにそのままdecidePolicyFor: WKNavigationActionが呼ばれることがあり、didCommitが呼ばれるまで終わりとは限らないので気をつける。
  • didCommitが呼ばれて初めてリダイレクトが終了したとわかるが、Webコンテンツのロードが始まらない限り最後のURLがわからない。

P.S.

その他のWKNavigationDelegateのメソッド

didStartProvisionalNavigationdidReceiveServerRedirectForProvisionalNavigationは呼ばれない事もあったのでよく分かっていない・・・

func webView(WKWebView, didStartProvisionalNavigation: WKNavigation!)

Called when web content begins to load in a web view.

これが呼ばれるとリダイレクトがおわりURLが決定しているようだった。これのあとに最後のリクエストかレスポンスがきた。

これのあとにリクエストが来た場合はそれが最後のリダイレクトなのか、そのあとにdidReceiveServerRedirectForProvisionalNavigationが呼ばれ、レスポンスヘッダがきた。

※ なぜか、呼ばれないこともあった…

func webView(WKWebView, didReceiveServerRedirectForProvisionalNavigation: WKNavigation!)

Called when a web view receives a server redirect.

リダイレクトされたときの最後のリクエストのあとで呼ばれ、そのあとにレスポンスがきた。

※ なぜか呼ばれないこともあった…

発表しました。「Swiftがだいたい読めるようになるセッション」

先日、社内勉強会でエンジニアがiOSを雰囲気で読めるようになるのを目指すLTをさせていただきました。

Swiftはとても読みやすい言語なので、普段読んだことのないエンジニアの人でも要所さえ抑えてしまえば言語自体は簡単に読めると思います。

加えてiOSアプリケーション開発の全体像を掴めば、iOSプロジェクトがだいたい雰囲気読めるようになるのではないかと思いこの発表をしてみました。

リーディングの題材としてはこちらのオープンソースプロジェクトをお借りしました。 少しリファクタしましたが、手頃なサンプルプロジェクトを1から作る時間が無かったので助かりました。 github.com

最近はAndroid界隈でKotlinというSwiftにとても似てる言語が普及している流れも有りAndroidエンジニアとiOSエンジニアでお互いにコードを読みやすくなっているので、僕もそろそろKotolinのAndroidを雰囲気で読めるようになっておきたいところです。