Help us understand the problem. What is going on with this article?

Chromeのストレージ永続化仕様を詳しく追ってみた

1. はじめに

ChromeやFirefoxで使えるストレージ永続化機能はご存知でしょうか。

https://developers.google.com/web/updates/2016/06/persistent-storagePersistent Storage という題で詳しく触れられていますが、通常 LRU で消去されてしまう IndexedDBCacheStorage の内容を永続化してくれる機能だと理解しています。

CacheStoragePersistent Storage の組み合わせは強力で、理論上大容量のアセットを永続化できるようになることから、PWAでのオフライン戦略や通信量削減戦略に対して強力な武器になると考えています。

しかし残念ながら、例によって Safari はサポートしていません。そのため、WebKit の利用を強制される iOS では、例え Chrome を使っていたとしてもこの恩恵を受けることはできません。

ただし Android では可能なので、Android だけでもユーザー体験を良くしたいという場合には効果があると考えています。

実際に自分が開発しているサービスでも Android だけ利用可能にしようと開発していましたが、実装・検証していく中で上で紹介した記事には言及されていない挙動をとる場合があることに気づきました。

具体的には、ストレージ永続化をおこなうためには window.navigator.storage.persist() という API をたたいてストレージ永続化用のパーミッションを取得しなければならず、パーミッション取得のために必要な条件がいくつか存在するのですが、その条件がどうにも記事中のものとは異なる気がしたのです。

そこで今回、検証中に遭遇した謎挙動の理由を探るべく chromium のソースを追ったり、デバッグ実行しながら詳細な挙動の確認をおこなったことで理由が掴めた気がしたので、忘れないうちにまとめようと思いました。

本記事では、 Persistent Storage 機能を利用するためにクリアしなければいけない条件を整理するために、 Google 側が記事中で提示している条件を挙げたあと、開発・検証中に遭遇した条件にそぐわない挙動を挙げ、chromium ソースコードの該当箇所を示しながらなぜそういった挙動になったのかを解き明かし、最終的により具体的なパーミッション取得条件を示します。

2. 公式で挙げられているパーミッション取得のための条件

条件を挙げる前に、この機能がサポートされたのが Chrome 55 からであるため、それ以前のバージョンではサポート外にしたほうが良いでしょう。

ですので、まず API を利用できるかどうかの判断として

  • Chrome 55 以上
  • window.navigator.storage.persistwindow.navigator.storage.persisted が存在する

を判定条件とするのが良さそうです。

これをクリアしたブラウザで、次に示す条件をひとつでもクリアするとストレージ永続化が利用できるようになります。

  • The site is bookmarked (and the user has 5 or less bookmarks)
  • The site has high site engagement
  • The site has been added to home screen
  • The site has push notifications enabled

https://developers.google.com/web/updates/2016/06/persistent-storage から引用

つまり

  • 対象サイトがブックマークされていること ( ただしブックマークの数は 5以下 でなければならない )
  • 対象サイトが high レベルのエンゲージメントスコアを出していること
  • 対象サイトが ホームスクリーン に追加されていること
  • 対象サイトがPUSH通知を許可していること

と書かれています。 site engagement というのは https://www.chromium.org/developers/design-documents/site-engagement で言及されているのですが、 chrome://site-engagement で確認することのできる各サイトのスコア ( 0 ~ 100 の間 ) です。スコアの計算方法は先に挙げた記事中で書かれていますが、サイト上でタッチ操作やスクロール操作をおこなったり、対象サイトをURL直打ちで開いたりすると上がります。ようは その人にとってどれだけ時間を割いて利用しているサイトかどうか というのを定量的に測ろうとした仕組みということで、例えば Google側がもっている対象サイトにおける絶対評価 とかではないというところがポイントになります。

つまり人によって超有名サイトであってもスコアが低くなる可能性はあるし、逆に誰も知らないような個人サイトであってもスコアが高くなる可能性はあります。

さて、この時点ですでに疑問が湧きました。 high って具体的にいくつだろう...?

3. 開発・検証する中で遭遇した謎挙動

3.1 high の定義がわからない

パーミッション取得のための条件は、 先に挙げた条件のどれかひとつでも満たしていれば良い。と書かれています。つまり、ブックマークしておらず、ホームスクリーンにも追加しておらず、PUSH通知の許可もしていないサイトであれば

  • 対象サイトが high レベルのエンゲージメントスコアを出していること

を満たさなければならないはずです。スコアは数値であり、 high という閾値がいくつなのかわからないにせよ、

50 でパーミッションが取得でき、 30 で取得できず、 10 で取得できるなんてことにはならないはずです。....ならない...はずなのです。

ですが実際は上記のようなケースが存在したため、 high の定義がなんなのか更にわからなくなりました。

3.2 どれかひとつの条件を満たせばいいという話ではなかったのか

いったんエンゲージメントスコアのことは忘れて、今度はブックマークを試してみました。

もちろんブックマーク数は 5 つ以下におさえた状態であるサイトを登録しました。

window.navigator.storage.persist() をたたくと true が返ってきます、成功です。

つづけて別のサイトをブックマークしてみました ( まだ 5 つ以下です )。

... false が返ってきました。

ブックマークした A というサイトも B というサイトも、他の条件はほぼ同じです。エンゲージメントスコアは共に 3 程度。ブックマーク前の状態で両方のサイトとも window.navigator.storage.persist() の結果で false が返ってくることも確認していました。

となると、条件を満たしていたとしてもダメな場合があるとしか想像できません。

何も信用できなくなってきました。

同様の現象は、自分では検証していないのですが Android 端末でホームスクリーンに追加した場合にも起きたようです。

Aという端末ではホームスクリーンに追加したとたん利用可能になったが、Bではホームスクリーンに追加してあるのにダメだったとのことでした。

4. ソースコードから仕様を追う

明らかにドキュメント化されていない仕様がありそうな気配を感じたので、 chromium のソースを落としてきてビルド、lldbでデバッグしたり printデバッグなどをおこないました。

(余談) 自分は macOS で作業したのですが、当初 GitHub 上の mirror からソースを落としてきてビルドしようと試みて途中でビルドエラーで怒られてしまったので、 https://chromium.googlesource.com/chromium/src/+/master/docs/mac_build_instructions.md にならって素直にその通りやるのがよかったです

4.1. 該当箇所のソースコードを見つける

今回どうやって window.navigator.storage.persist() の実装箇所を見つけたのか、その過程をできるだけ詳細に残しておこうと思います。ソースコードを追っていく流れをそのまま書いているので、過程に興味がない方はこの章をスキップしていただくと、ピンポイントで関係のあるソースコードの箇所からの説明からになります。

まずはじめに、 window.navigator.storage.persist() をたたいたときに呼ばれるソースコードを見てみます。これは https://github.com/chromium/chromium/blob/master/third_party/blink/renderer/modules/quota/storage_manager.cc#L82-L104 に書かれていて、中で RequestPermission を呼んでいるのがわかります。

ソースコードの読み始めのポイントとしては、 JavaScript 側でのAPI呼び出しに対応するC++側のAPIが必ずあるはずなので、その処理を見つけることです。今回であれば persist という名前で検索すればある程度しぼりこむことができます。

  • storage_manager.cc
ScriptPromise StorageManager::persist(ScriptState* script_state) {
  auto* resolver = MakeGarbageCollected<ScriptPromiseResolver>(script_state);
  ScriptPromise promise = resolver->Promise();
  ExecutionContext* execution_context = ExecutionContext::From(script_state);
  DCHECK(execution_context->IsSecureContext());  // [SecureContext] in IDL
  const SecurityOrigin* security_origin =
      execution_context->GetSecurityOrigin();
  if (security_origin->IsOpaque()) {
    resolver->Reject(V8ThrowException::CreateTypeError(
        script_state->GetIsolate(), kUniqueOriginErrorMessage));
    return promise;
  }

  Document* doc = To<Document>(execution_context);
  GetPermissionService(ExecutionContext::From(script_state))
      .RequestPermission(
          CreatePermissionDescriptor(PermissionName::DURABLE_STORAGE),
          LocalFrame::HasTransientUserActivation(doc->GetFrame()),
          WTF::Bind(&StorageManager::PermissionRequestComplete,
                    WrapPersistent(this), WrapPersistent(resolver)));

  return promise;
}

ここでひとつ重要なことがわかりました。 chromium の中で Persistent Storage のパーミッションを扱う際のキーワードが DURABLE_STORAGE だということです。

RequestPermission の実装は https://github.com/chromium/chromium/blob/master/content/browser/permissions/permission_service_impl.cc#L162-L171 にあり

読み進めていくと
https://github.com/chromium/chromium/blob/master/content/browser/permissions/permission_service_impl.cc#L215-L222 のあたりで

  • permission_service_impl.cc
int id =
      PermissionControllerImpl::FromBrowserContext(browser_context)
          ->RequestPermissions(
              types, context_->render_frame_host(), origin_.GetURL(),
              user_gesture,
              base::Bind(&PermissionServiceImpl::OnRequestPermissionsResponse,
                         weak_factory_.GetWeakPtr(), pending_request_id));

に気づきます。ここで BrowserContext なるものを使って PermissionController 経由で RequestPermissions を呼び出しています。 PermissionController::RequestPermissions の処理は https://github.com/chromium/chromium/blob/master/content/browser/permissions/permission_controller_impl.cc#L199-L229 に書かれていて、 PermissionControllerDelegate のインスタンスを BrowserContextGetPermissionControllerDelegate() 経由で取得し、delegateRequestPermissions を呼んでいます。

  • permission_controller_impl.cc
int PermissionControllerImpl::RequestPermissions(
    const std::vector<PermissionType>& permissions,
    RenderFrameHost* render_frame_host,
    const GURL& requesting_origin,
    bool user_gesture,
    const base::Callback<
        void(const std::vector<blink::mojom::PermissionStatus>&)>& callback) {
  for (PermissionType permission : permissions)
    NotifySchedulerAboutPermissionRequest(render_frame_host, permission);

  auto it = devtools_permission_overrides_.find(requesting_origin.GetOrigin());
  if (it != devtools_permission_overrides_.end()) {
    std::vector<blink::mojom::PermissionStatus> result;
    for (auto& permission : permissions)
      result.push_back(GetPermissionOverrideStatus(it->second, permission));
    callback.Run(result);
    return kNoPendingOperation;
  }

  PermissionControllerDelegate* delegate =
      browser_context_->GetPermissionControllerDelegate();
  if (!delegate) {
    std::vector<blink::mojom::PermissionStatus> result(
        permissions.size(), blink::mojom::PermissionStatus::DENIED);
    callback.Run(result);
    return kNoPendingOperation;
  }
  return delegate->RequestPermissions(permissions, render_frame_host,
                                      requesting_origin, user_gesture,
                                      callback);
}

この GetPermissionControllerDelegateBrowserContext では pure virtual として宣言されており、継承先で実装するようになっています。継承先はブラウザの種類によって多岐にわたり、 chromecast や headless browser などいくつかの実装があるようです。

今回見たいのはおそらく https://github.com/chromium/chromium/blob/master/chrome/browser/profiles/profile_impl.cc#L1215-L1217 で実装されているところで、中で PermissionManagerFactory をつかっているのがわかります。

  • profile_impl.cc
content::PermissionControllerDelegate*
ProfileImpl::GetPermissionControllerDelegate() {
  return PermissionManagerFactory::GetForProfile(this);
}

PermissionManagerFactory::GetForProfile の実装は https://github.com/chromium/chromium/blob/master/chrome/browser/permissions/permission_manager_factory.cc#L14-L18 に書かれており、 GetServiceForBrowserContext を通して PermissionManager インスタンスを作っていることがわかります。 つまり PermissionControllerDelegate の正体は PermissionManager クラスで、結局のところこのクラスの RequestPermissions を呼んでいることがわかりました。

  • permission_manager_factory.cc
PermissionManager*
PermissionManagerFactory::GetForProfile(Profile* profile) {
  return static_cast<PermissionManager*>(
      GetInstance()->GetServiceForBrowserContext(profile, true));
}

PermissionManager::RequestPermissions の実装は https://github.com/chromium/chromium/blob/master/chrome/browser/permissions/permission_manager.cc#L393-L445 に書かれており、読み進めると

  • permission_manager.cc
 PermissionContextBase* context = GetPermissionContext(permission);
    DCHECK(context);

    context->RequestPermission(
        web_contents, request, canonical_requesting_origin, user_gesture,
        base::BindOnce(
            &PermissionResponseCallback::OnPermissionsRequestResponseStatus,
            std::move(response_callback)));

GetPermissionContextpermission を引数に ( ここでは DURABLE_STORAGE ) 呼んでいるのがわかります。 GetPermissionContext の実装は

PermissionContextBase* PermissionManager::GetPermissionContext(
    ContentSettingsType type) {
  const auto& it = permission_contexts_.find(type);
  return it == permission_contexts_.end() ? nullptr : it->second.get();
}

のようになっていて、 ContentSettingsTypeCONTENT_SETTINGS_TYPE_DURABLE_STORAGE で検索していることがわかります。ではこの permission_contexts_ がどうやって初期化されているかというと、 https://github.com/chromium/chromium/blob/master/chrome/browser/permissions/permission_manager.cc#L280-L334 に初期化処理があり

PermissionManager::PermissionManager(Profile* profile) : profile_(profile) {
  ... () ...
  permission_contexts_[CONTENT_SETTINGS_TYPE_DURABLE_STORAGE] =
      std::make_unique<DurableStoragePermissionContext>(profile);
  ... () ...
}

正体が DurableStoragePermissionContext だとわかりました!
では この実装を読みにいきます。

4.2 該当箇所のソースを読む

window.navigator.storage.persist() を呼んだ時にめぐりめぐって呼ばれるのは DurableStoragePermissionContext::DecidePermission という部分です。

https://github.com/chromium/chromium/blob/master/chrome/browser/storage/durable_storage_permission_context.cc#L35-L99

  • durable_storage_permission_context.cc
void DurableStoragePermissionContext::DecidePermission(
    content::WebContents* web_contents,
    const PermissionRequestID& id,
    const GURL& requesting_origin,
    const GURL& embedding_origin,
    bool user_gesture,
    BrowserPermissionCallback callback) {
  DCHECK(content::BrowserThread::CurrentlyOn(content::BrowserThread::UI));
  DCHECK_NE(CONTENT_SETTING_ALLOW,
            GetPermissionStatus(nullptr /* render_frame_host */,
                                requesting_origin, embedding_origin)
                .content_setting);
  DCHECK_NE(CONTENT_SETTING_BLOCK,
            GetPermissionStatus(nullptr /* render_frame_host */,
                                requesting_origin, embedding_origin)
                .content_setting);

  // Durable is only allowed to be granted to the top-level origin. Embedding
  // origin is the last committed navigation origin to the web contents.
  if (requesting_origin != embedding_origin) {
    NotifyPermissionSet(id, requesting_origin, embedding_origin,
                        std::move(callback), false /* persist */,
                        CONTENT_SETTING_DEFAULT);
    return;
  }

  scoped_refptr<content_settings::CookieSettings> cookie_settings =
      CookieSettingsFactory::GetForProfile(profile());

  // Don't grant durable for session-only storage, since it won't be persisted
  // anyway. Don't grant durable if we can't write cookies.
  if (cookie_settings->IsCookieSessionOnly(requesting_origin) ||
      !cookie_settings->IsCookieAccessAllowed(requesting_origin,
                                              requesting_origin)) {
    NotifyPermissionSet(id, requesting_origin, embedding_origin,
                        std::move(callback), false /* persist */,
                        CONTENT_SETTING_DEFAULT);
    return;
  }

  const size_t kMaxImportantResults = 10;
  std::vector<ImportantSitesUtil::ImportantDomainInfo> important_sites =
      ImportantSitesUtil::GetImportantRegisterableDomains(profile(),
                                                          kMaxImportantResults);

  std::string registerable_domain =
      net::registry_controlled_domains::GetDomainAndRegistry(
          requesting_origin,
          net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES);
  if (registerable_domain.empty() && requesting_origin.HostIsIPAddress())
    registerable_domain = requesting_origin.host();

  for (const auto& important_site : important_sites) {
    if (important_site.registerable_domain == registerable_domain) {
      NotifyPermissionSet(id, requesting_origin, embedding_origin,
                          std::move(callback), true /* persist */,
                          CONTENT_SETTING_ALLOW);
      return;
    }
  }

  NotifyPermissionSet(id, requesting_origin, embedding_origin,
                      std::move(callback), false /* persist */,
                      CONTENT_SETTING_DEFAULT);
}

このうち、 persist() の結果で true を返してくれるのは

const size_t kMaxImportantResults = 10;
  std::vector<ImportantSitesUtil::ImportantDomainInfo> important_sites =
      ImportantSitesUtil::GetImportantRegisterableDomains(profile(),
                                                          kMaxImportantResults);

  std::string registerable_domain =
      net::registry_controlled_domains::GetDomainAndRegistry(
          requesting_origin,
          net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES);
  if (registerable_domain.empty() && requesting_origin.HostIsIPAddress())
    registerable_domain = requesting_origin.host();

  for (const auto& important_site : important_sites) {
    if (important_site.registerable_domain == registerable_domain) {
      NotifyPermissionSet(id, requesting_origin, embedding_origin,
                          std::move(callback), true /* persist */,
                          CONTENT_SETTING_ALLOW);
      return;
    }
  }

の部分で、 ImportantSitesUtil::GetImportantRegisterableDomains で返ってきた結果の中に、対象サイトから取得した registerable_domain が存在していれば true が返っているとわかります。ここで非常に重要なことは、 ImportantSitesUtil::GetImportantRegisterableDomains の結果が 最大10件 だということ。もうひとつは、 registerable_domain の決定方法 です。

registerable_domain は、ドメイン名によるでしょうが第2・第3レベルまでのドメイン名になるようです。例えば、 https://mail.google.com であれば google.comregisterable_domain になり、 https://www.google.co.jp の場合は google.co.jp になります。このルールが先に挙げた謎の挙動のヒントになってきます。

つづいて、ImportantSitesUtil::GetImportantRegisterableDomains を見にいきます。
実装は https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/important_sites_util.cc#L383-L424 に書かれています

  • important_sites_util.cc
std::vector<ImportantDomainInfo>
ImportantSitesUtil::GetImportantRegisterableDomains(Profile* profile,
                                                    size_t max_results) {
  std::map<std::string, ImportantDomainInfo> important_info;
  std::map<GURL, double> engagement_map;

  PopulateInfoMapWithEngagement(profile, blink::mojom::EngagementLevel::MEDIUM,
                                &engagement_map, &important_info);

  PopulateInfoMapWithContentTypeAllowed(
      profile, CONTENT_SETTINGS_TYPE_NOTIFICATIONS,
      ImportantReason::NOTIFICATIONS, &important_info);

  PopulateInfoMapWithContentTypeAllowed(
      profile, CONTENT_SETTINGS_TYPE_DURABLE_STORAGE, ImportantReason::DURABLE,
      &important_info);

  PopulateInfoMapWithBookmarks(profile, engagement_map, &important_info);

  std::unordered_set<std::string> blacklisted_domains =
      GetBlacklistedImportantDomains(profile);

  std::vector<std::pair<std::string, ImportantDomainInfo>> items(
      important_info.begin(), important_info.end());
  std::sort(items.begin(), items.end(), &CompareDescendingImportantInfo);

  std::vector<ImportantDomainInfo> final_list;
  for (std::pair<std::string, ImportantDomainInfo>& domain_info : items) {
    if (final_list.size() >= max_results)
      return final_list;
    if (blacklisted_domains.find(domain_info.first) !=
        blacklisted_domains.end()) {
      continue;
    }
    final_list.push_back(domain_info.second);
    RECORD_UMA_FOR_IMPORTANT_REASON(
        "Storage.ImportantSites.GeneratedReason",
        "Storage.ImportantSites.GeneratedReasonCount",
        domain_info.second.reason_bitfield);
  }

  return final_list;
}

ここではじめに紹介したパーミッション取得のための条件を思い出します。

  • 対象サイトがブックマークされていること ( ただしブックマークの数は 5以下 でなければならない )
  • 対象サイトが high レベルのエンゲージメントスコアを出していること
  • 対象サイトが ホームスクリーン に追加されていること
  • 対象サイトがPUSH通知のパーミッションを許可していること

上記の条件がまさにコード中に書かれているのが分かります。
ざっと処理の流れを説明すると

std::map<std::string, ImportantDomainInfo> important_info;

に対してひとつずつ条件を照らし合わせながら該当するものを追加していき、

CompareDescendingImportantInfo のルールにのっとってこれを sort した後、blacklist にのっているドメインを除きつつ、最大10件になるように final_list を構築して返却しています。

ここで先ほど 最大10件 というのが重要だと説明しましたが、 important_info に10件より多くの registerable_domain が入っていたとしても、

sortの結果によって11件目以降になると、 final_list の中には現れないということがわかります。

つまり、具体的にどういった条件で important_info に追加されていくのかという情報と同じかそれ以上に、 sort の条件が気になります。

CompareDescendingImportantInfo の実装はどうなっているでしょうか。

https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/important_sites_util.cc#L192-L203 に書かれており

bool CompareDescendingImportantInfo(
    const std::pair<std::string, ImportantDomainInfo>& a,
    const std::pair<std::string, ImportantDomainInfo>& b) {
  int score_a = GetScoreForReasonsBitfield(a.second.reason_bitfield);
  int score_b = GetScoreForReasonsBitfield(b.second.reason_bitfield);
  int bitfield_diff = score_a - score_b;
  if (bitfield_diff != 0)
    return bitfield_diff > 0;
  return a.second.engagement_score > b.second.engagement_score;
}

ImportantDomainInfo に含まれる reason_bitfield の値によって得たスコアどうしを比較しており、これが大きい方が優先されるとわかります。
もし reason_bitfield が同じ値の場合は、純粋な engagement_score ( chrome://site-engagement で見れる値 ) の大きい方が優先されるようです。 

reason_bitfield の値は、複数の reason に分解した後、ひとつずつ以下の GetScoreForReason を通してスコアに変換して足し合わせています。

int GetScoreForReason(ImportantReason reason) {
  switch (reason) {
    case ImportantReason::ENGAGEMENT:
      return 1 << 0;
    case ImportantReason::DURABLE:
      return 1 << 1;
    case ImportantReason::BOOKMARKS:
      return 1 << 2;
    case ImportantReason::HOME_SCREEN:
      return 1 << 3;
    case ImportantReason::NOTIFICATIONS:
      return 1 << 4;
    case ImportantReason::REASON_BOUNDARY:
      return 0;
  }
  return 0;
}

たとえば、 ImportantReason::ENGAGEMENTImportantReason::DURABLE を同時に満たすreason_bitfield のスコアは 3 になります。

ここからわかることは、各条件にはそれぞれ優先度が存在し、例えばブックマークに追加することよりもホームスクリーンに追加したりPUSH通知を許可するほうが優先的に選ばれることになるとわかります。

更に、条件は重ねて適応することができ、例えば同じブックマークに追加したサイトであっても ImportantReason::ENGAGEMENT を満たしているかでスコアが 1 点 変わることがわかります。

ここまででだいぶ理解は進みましたが、最後に各条件がより具体的にどういったことを求めているかを見ていきたいと思います ( engagement score の high レベルの定義も気になります )。

4.2.1 high site engagement の意味を知る

PopulateInfoMapWithEngagement の中を見ます (
https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/important_sites_util.cc#L236-L270 )

void PopulateInfoMapWithEngagement(
    Profile* profile,
    blink::mojom::EngagementLevel minimum_engagement,
    std::map<GURL, double>* engagement_map,
    std::map<std::string, ImportantDomainInfo>* output) {
  SiteEngagementService* service = SiteEngagementService::Get(profile);
  std::vector<mojom::SiteEngagementDetails> engagement_details =
      service->GetAllDetails();
  std::set<GURL> content_origins;

  // We can have multiple origins for a single domain, so we record the one
  // with the highest engagement score.
  for (const auto& detail : engagement_details) {
    if (detail.installed_bonus > 0) {
      // This origin was recently launched from the home screen.
      MaybePopulateImportantInfoForReason(detail.origin, &content_origins,
                                          ImportantReason::HOME_SCREEN, output);
    }

    (*engagement_map)[detail.origin] = detail.total_score;

    if (!service->IsEngagementAtLeast(detail.origin, minimum_engagement))
      continue;

    std::string registerable_domain =
        ImportantSitesUtil::GetRegisterableDomainOrIP(detail.origin);
    ImportantDomainInfo& info = (*output)[registerable_domain];
    if (detail.total_score > info.engagement_score) {
      info.registerable_domain = registerable_domain;
      info.engagement_score = detail.total_score;
      info.example_origin = detail.origin;
      info.reason_bitfield |= 1 << ImportantReason::ENGAGEMENT;
    }
  }
}

engagement_details というのは、 chrome://site-engagement で見れるリストが返ってきていると思ってもらっても大丈夫です。このリストを traverse しながら、 installed_bonus の値が 0 より大きければ ホームスクリーン追加用の reason_bitfield ( ImportantReason::HOME_SCREEN ) を追加しつつ、 minimum_engagement 以上のスコアであるもののみに reason_bitfield ( ImportantReason::ENGAGEMENT ) を追加します。

ここで high の具体的な条件がわかりました。 minimum_engagement です。これは引数で与えられたものだったので、呼び出し元に戻ると blink::mojom::EngagementLevel::MEDIUM を渡していることがわかります。この上で IsEngagementAtLeast の実装を読んでみます。

https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/site_engagement_service.cc#L248-L270 に書かれており、 blink::mojom::EngagementLevel::MEDIUM に相当する条件が score >= SiteEngagementScore::GetMediumEngagementBoundary() だとわかります。

  • site_engagement_service.cc
bool SiteEngagementService::IsEngagementAtLeast(
    const GURL& url,
    blink::mojom::EngagementLevel level) const {
  DCHECK_LT(SiteEngagementScore::GetMediumEngagementBoundary(),
            SiteEngagementScore::GetHighEngagementBoundary());
  double score = GetScore(url);
  switch (level) {
    case blink::mojom::EngagementLevel::NONE:
      return true;
    case blink::mojom::EngagementLevel::MINIMAL:
      return score > 0;
    case blink::mojom::EngagementLevel::LOW:
      return score >= 1;
    case blink::mojom::EngagementLevel::MEDIUM:
      return score >= SiteEngagementScore::GetMediumEngagementBoundary();
    case blink::mojom::EngagementLevel::HIGH:
      return score >= SiteEngagementScore::GetHighEngagementBoundary();
    case blink::mojom::EngagementLevel::MAX:
      return score == SiteEngagementScore::kMaxPoints;
  }
  NOTREACHED();
  return false;
}

そこで SiteEngagementScore::GetMediumEngagementBoundary() を読みにいくと
https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/site_engagement_score.cc#L146-L148

  • site_engagement_score.cc
double SiteEngagementScore::GetMediumEngagementBoundary() {
  return GetParamValues()[MEDIUM_ENGAGEMENT_BOUNDARY].second;
}

と書かれており、
https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/site_engagement_score.cc#L85-L86

から 15 点が閾値だとわかりました!

ここで先ほど軽く説明して終わったホームスクリーンへの追加についても深掘りしたいと思います。

4.2.2 「ホームスクリーンに追加」の具体的な条件を知る

4.2.1 の PopulateInfoMapWithEngagement の実装の説明の中で、 installed_bonus > 0 であれば ImportantReason::HOME_SCREENreason_bitfield に足されると説明しましたが、では具体的に installed_bonus が 0 より大きくなるためにはどうなればいいのでしょうか。

これを知るためには

 std::vector<mojom::SiteEngagementDetails> engagement_details =
      service->GetAllDetails();

の中を知る必要があります。
この処理は最終的にサイトごとに SiteEngagementScore::GetDetails() が呼ばれるのですが、 https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/site_engagement_score.cc#L265-L276 のあたりを見ると、どうやって mojom::SiteEngagementDetails ができているのかがわかります

  • site_engagement_score.cc
double SiteEngagementScore::GetTotalScore() const {
  return std::min(DecayedScore() + BonusIfShortcutLaunched(), kMaxPoints);
}

mojom::SiteEngagementDetails SiteEngagementScore::GetDetails() const {
  mojom::SiteEngagementDetails engagement;
  engagement.origin = origin_;
  engagement.base_score = DecayedScore();
  engagement.installed_bonus = BonusIfShortcutLaunched();
  engagement.total_score = GetTotalScore();
  return engagement;
}

ここで注目したいのは BonusIfShortcutLaunched() です。この結果が installed_bonus の値にもなるし、 total_score にも反映されます。

double SiteEngagementScore::BonusIfShortcutLaunched() const {
  int days_since_shortcut_launch =
      (clock_->Now() - last_shortcut_launch_time_).InDays();
  if (days_since_shortcut_launch <= kMaxDaysSinceShortcutLaunch)
    return GetWebAppInstalledPoints();
  return 0;
}

実装は上記のようになっていて、kMaxDaysSinceShortcutLaunchhttps://github.com/chromium/chromium/blob/master/chrome/browser/engagement/site_engagement_score.cc#L34 に書かれていますが 10 です。

つまり最後にホームスクリーン上に追加したアプリから起動したのが10日以内であれば GetWebAppInstalledPoints() ( ちなみに 5 点です https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/site_engagement_score.cc#L81 ) を付与するよということを表しています。

ここから、例えホームスクリーンに追加していたとしても、10日以上起動していなければインストールボーナスはつかない。つまり reason_bitfieldImportantReason::HOME_SCREEN は追加しないということがわかります。

ここまででもう一度 ImportantSitesUtil::GetImportantRegisterableDomains の実装に戻りましょう。 4.2.1 と 4.2.2 では PopulateInfoMapWithEngagement の中を具体的にみていきました。

そのあとに書かれている

PopulateInfoMapWithContentTypeAllowed(
      profile, CONTENT_SETTINGS_TYPE_NOTIFICATIONS,
      ImportantReason::NOTIFICATIONS, &important_info);

の部分は読んでそのままで、 PUSH通知をすでに許可している場合は、 ImportantReason::NOTIFICATIONSreason_bitfield に加えられます。

続けて書いてある

PopulateInfoMapWithContentTypeAllowed(
      profile, CONTENT_SETTINGS_TYPE_DURABLE_STORAGE, ImportantReason::DURABLE,
      &important_info);

は、 すでにDURABLE_STORAGEの条件をみたしている場合ImportantReason::DURABLEreason_bitfield に加えるという意味になります。つまり、一度でも window.navigatgor.storage.persist() でストレージ永続化できていた場合はここで必ず reason_bitfield に 2点 加算されます。これも registerable_domain の優先度を決める上で非常に重要になってきます。

こうなると残りは

PopulateInfoMapWithBookmarks(profile, engagement_map, &important_info);

だけなので、次でブックマークに関する具体的な条件をみていきます

4.2.3 「ブックマークに追加」の具体的な条件を知る

実装は https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/important_sites_util.cc#L307-L352 に書かれており、ブックマークの数が kMaxBookmarks を超えているかどうかで処理が異なることがわかります。

kMaxBookmarkshttps://github.com/chromium/chromium/blob/master/chrome/browser/engagement/important_sites_util.cc#L61 に書かれていますが 5 です。

つまり、条件になっていた「ブックマーク数が5以下だったら」の数ですね。

void PopulateInfoMapWithBookmarks(
    Profile* profile,
    const std::map<GURL, double>& engagement_map,
    std::map<std::string, ImportantDomainInfo>* output) {
  SiteEngagementService* service = SiteEngagementService::Get(profile);
  BookmarkModel* model =
      BookmarkModelFactory::GetForBrowserContextIfExists(profile);
  if (!model)
    return;
  std::vector<UrlAndTitle> untrimmed_bookmarks;
  model->GetBookmarks(&untrimmed_bookmarks);

  // Process the bookmarks and optionally trim them if we have too many.
  std::vector<UrlAndTitle> result_bookmarks;
  if (untrimmed_bookmarks.size() > kMaxBookmarks) {
    std::copy_if(untrimmed_bookmarks.begin(), untrimmed_bookmarks.end(),
                 std::back_inserter(result_bookmarks),
                 [service](const UrlAndTitle& entry) {
                   return service->IsEngagementAtLeast(
                       entry.url.GetOrigin(),
                       blink::mojom::EngagementLevel::LOW);
                 });
    // TODO(dmurph): Simplify this (and probably much more) once
    // SiteEngagementService::GetAllDetails lands (crbug/703848), as that will
    // allow us to remove most of these lookups and merging of signals.
    std::sort(
        result_bookmarks.begin(), result_bookmarks.end(),
        [&engagement_map](const UrlAndTitle& a, const UrlAndTitle& b) {
          auto a_it = engagement_map.find(a.url.GetOrigin());
          auto b_it = engagement_map.find(b.url.GetOrigin());
          double a_score = a_it == engagement_map.end() ? 0 : a_it->second;
          double b_score = b_it == engagement_map.end() ? 0 : b_it->second;
          return a_score > b_score;
        });
    if (result_bookmarks.size() > kMaxBookmarks)
      result_bookmarks.resize(kMaxBookmarks);
  } else {
    result_bookmarks = std::move(untrimmed_bookmarks);
  }

  std::set<GURL> content_origins;
  for (const UrlAndTitle& bookmark : result_bookmarks) {
    MaybePopulateImportantInfoForReason(bookmark.url, &content_origins,
                                        ImportantReason::BOOKMARKS, output);
  }
}

ブックマーク数が5以下の場合は、 result_bookmarks にそのままそれらが入り、 ImportantReason::BOOKMARKSreason_bitfield に追加されます。

そうでない場合は 全ブックマークの中から blink::mojom::EngagementLevel::LOW ( https://github.com/chromium/chromium/blob/master/chrome/browser/engagement/site_engagement_service.cc#L259-L260 から 1 ) 以上のスコアのものを集めたあと、 engagement_score の値によって sort し、上から 5件 を result_bookmarks に追加するという挙動になるようです。

つまり、5件以下であればそのまま ImportantReason::BOOKMARKS が追加され、そうでなかったとしても、ブックマークしたサイトのスコアが 1 以上であり、かつスコアの大きい順の上位5つ以内に入っていれば同様に ImportantReason::BOOKMARKSreason_bitfield に足されるとわかります。

5. 謎だった挙動を解明する

4章でソースコードを追いながら詳細な仕様を把握できました。

このことから不可解だった次の挙動にも説明ができるようになります。

  • スコアが 50 でパーミッションが取得でき、 30 で取得できず、 10 で取得できてしまう
  • ブックマークした A というサイトと B というサイトがあるが A しかパーミッションを取得できない
  • ホームスクリーンに追加してもパーミッションを取得できない

まず条件をあらためて整理します。

もともとの条件はこうでした。

  • 対象サイトがブックマークされていること ( ただしブックマークの数は 5以下 でなければならない )
  • 対象サイトが high レベルのエンゲージメントスコアを出していること
  • 対象サイトが ホームスクリーン に追加されていること
  • 対象サイトがPUSH通知のパーミッションを許可していること

その上、調べていくと次のことがわかりました

  • パーミッションを許可できるのは上位10件のサイトまで
  • 別のサイトであっても registerable_domain は被る可能性がある
  • エンゲージメントスコアだけを条件にする場合は15点以上必要
  • ホームスクリーンに追加することを条件にするなら、最後に起動してから10日以内でなければならない
  • すでにパーミッションを与えているサイトには reason_bitfield2 点が加わる
  • ブックマークが5件を超える場合は、スコアが1点以上のサイトを対象に上位5件に対して reason_bitfield4 点を加える

これをふまえると

  • スコアが 50 でパーミッションが取得でき、 30 で取得できず、 10 で取得できてしまう

この場合は、 50 でパーミションがとれたのはスコアが 15 点以上だったからなのは良いとして、 10 でも取得できたのはドメイン名が 50 のものと同じだった可能性が高いです( 実際の自分の場合は https://mail.google.com が上位にきていて、 10 のは別の google.com ドメインのサイトでした )。逆になぜ 30 でもパーミッションがとれなかったのかは、上位 10件 に漏れてしまっていたからだと考えられます。

罠だったのは、検証のために複数のサイトで window.navigator.storage.persist() を実行してまわっていったため、一度許可された場合に DURABLE_STORAGE の条件が満たされ、エンゲージメントスコアが 15点以上のサイトは常に reason_bitfield3 になるという挙動をとる点でした ( ImportantReason::ENGAGEMENT が 1点、 ImportantReason::DURABLE_STORAGE が 2点 )。この条件を満たしたものが 10 件を超えていると、新しいサイトでパーミッションを取得しようとした場合に、仮に 15点以上だったとしても、 reason_bitfield の値が ImportantReason::ENGAGEMENT だけのため ( 1 点 )、永遠にパーミッションを許可できない状態になっていました。

同様に

  • ブックマークした A というサイトと B というサイトがあるが A しかパーミッションを取得できない

この件でも、すでに対象のサイトが10件を超えている場合に問題になります。

もしブックマークの条件が満たされていたとしても、サイトのスコアが 15 点未満の場合は ImportantReason::BOOKMARKS ( 4 点 ) しか適応されていません。

このとき、他にホームスクリーンに追加したアプリが複数あったり、ブックマークとスコア15以上を同時に満たすサイトが10件以上ある状況だと、例えブックマークの登録が5件以内だったとしてもパーミッションを取得できない事態になります。

  • ホームスクリーンに追加してもパーミッションを取得できない

この場合は、ホームスクリーンへ追加することの優先度がとても高いことから、他のサイトに優先順位で負けることがあまり考えられないため、アプリを最後に起動したのが10日以上前である可能性が高いです。

6. まとめ

Persistent Storage のパーミッションを取得するための条件は

  • 対象サイトがブックマークされていること ( ただしブックマークの数は 5以下 でなければならない )
  • 対象サイトが high レベルのエンゲージメントスコアを出していること
  • 対象サイトが ホームスクリーン に追加されていること
  • 対象サイトがPUSH通知のパーミッションを許可していること

だけでなく、ソースコードを追うことでさらに細かな条件があることがわかりました。

具体的には

  • パーミッションを許可できるのは上位 10 件の registerable_domain に合致するサイトだけ
  • 別のサイトであっても registerable_domain は被る可能性がある ( a.google.comb.google.com はどちらも google.comregisterable_domain )
  • エンゲージメントスコアだけを条件にする場合は 15 点以上必要
  • ホームスクリーンに追加することを条件にするなら、最後に起動してから 10 日以内でなければならない
  • ブックマークが5件を超える場合は、エンゲージメントスコアが1点以上のサイトを対象に上位5件が加点対象になる
  • エンゲージメントスコアの条件を満たすと reason_bitfield1 点が加わる
  • すでにパーミッションを与えているサイトには reason_bitfield2 点が加わる
  • ブックマークに追加しているサイトにはreason_bitfield4 点を加える
  • ホームスクリーンに追加されているサイトには reason_bitfield8 点が加わる
  • PUSH通知を許可しているサイトには reason_bitfield16 点が加わる
  • reason_bitfield の合計が大きい順に10件を抽出する。 reason_bitfield が同じ場合はエンゲージメントスコアが高い方を優先する

のような条件が裏にあり決められています。

これをふまえると、なかなかサービスの機能としてストレージ永続化を提供するのは難しいなと感じましたが、詳細な条件を知れたことで大分スッキリしましたし、対策も考えやすそうです。

もしすでに謎挙動を観測された経験がある方がいらっしゃれば、
この記事の結論と相違ないかコメントをいただければ幸いです!

7. FAQ ( 追記 )

記事中にわかりにくいなという箇所がいくつかあったので、疑問になりそうなところをあらかじめ解説しておこうと思います。

7.1 異なるサイトで registerable_domain が一緒だった場合の挙動をもう一度

例えば a.google.comb.google.com があって、それぞれエンゲージメントスコアが 201 だったとします。
条件から考えると a.google.com はエンゲージメントスコアが 15 より大きいためパーミッションを取得できますが、 b.google.com では一見ダメそうです。しかし、これらは registerable_domain が同じ google.com なので、 google.com が 許可すべきドメイン 10 件の中に入っていさえすればパーミッションは取得できます

7.2 得点の説明が reason_bitfield と エンゲージメントスコアと 2種類あってわかりにくい

エンゲージメントスコアは chrome://site-engagement でみれる 0 ~ 100 の間の点数です。サイトによって異なります。

この値は、 最大10件の registerable_domain のリストを作るときに利用されます。

また、 reason_bitfield の値も同様にリストを作るときに利用する値ですが、基本的には reason_bitfield の値で 降順に並べ、同じ reason_bitfield のサイトどうしを比較する際にはじめてエンゲージメントスコアを参照する流れです。

エンゲージメントスコアは 15 点以上であるかが基準になっており、 15 以上であれば reason_bitfield に 1点加算されます。

7.3 一度許可したサイトのパーミッションは消せるのか

ブラウザの再起動等では消えないので、別な削除手段をとる必要がありそうです。一度許可してしまうと、常に reason_bitfield に 2点 加算されます。

goccy
[前職] mixi, Inc. [言語] C / C++ / Perl / Ruby / Python / Java / JavaScript / TypeScript / Go / Objective-C(++) / Swift
https://github.com/goccy
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした