// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "ios/chrome/browser/sessions/model/session_migration.h"
#import <Foundation/Foundation.h>
#import <optional>
#import "base/apple/foundation_util.h"
#import "base/files/file.h"
#import "base/files/file_enumerator.h"
#import "base/files/file_util.h"
#import "base/logging.h"
#import "base/notreached.h"
#import "base/strings/stringprintf.h"
#import "base/strings/sys_string_conversions.h"
#import "ios/chrome/browser/sessions/model/proto/storage.pb.h"
#import "ios/chrome/browser/sessions/model/session_constants.h"
#import "ios/chrome/browser/sessions/model/session_internal_util.h"
#import "ios/chrome/browser/sessions/model/session_ios.h"
#import "ios/chrome/browser/sessions/model/session_tab_group.h"
#import "ios/chrome/browser/sessions/model/session_window_ios.h"
#import "ios/chrome/browser/sessions/model/tab_group_util.h"
#import "ios/web/public/session/crw_session_storage.h"
#import "ios/web/public/session/crw_session_user_data.h"
#import "ios/web/public/session/proto/storage.pb.h"
#import "ios/web/public/web_state_id.h"
// This file provides utilities to migrate storage for sessions from the
// legacy to the optimized format (or reciprocally).
//
// The functions performs the conversion without using the code from the
// SessionServiceIOS or SessionRestorationServiceImpl as those services
// are designed to load/save sessions for a Browser and instantiate the
// WebStates, but we want to be able to convert the storage without the
// creation of all individual objects.
//
// For this reason, those migration functions are separate implementation
// but they heavily depends on the file layout used by those services. So
// any change to the services should be reflected here.
// The legacy storage is the following:
// ${BrowserStatePath}/
// Sessions/
// ${SessionID}/
// session.plist
// ...
// Web_Sessions/
// ${WebStateID}
// ...
// The optimized storage is the following:
// ${BrowserStatePath}
// SessionStorage/
// ${SessionID}/
// session_metadata.pb
// ${WebStateID}/
// data.pb
// state.pb
// ...
// ...
namespace ios::sessions {
namespace {
// Helper class used to simplify the conversion of session between legacy
// and optimised format.
class OptimizedSession {
public:
// Creates an instance from `legacy_session` in legacy format.
static std::optional<OptimizedSession> FromLegacy(
SessionWindowIOS* legacy_session);
// Creates an instance loading a session in optimized format from
// `session_dir`.
static std::optional<OptimizedSession> FromPath(
const base::FilePath& session_dir);
// Converts the session to legacy format.
SessionWindowIOS* ToLegacy() const;
// Saves the session in optimised format at `session_dir`. The native
// WKWebView session data can be found in `web_sessions`.
bool SaveTo(const base::FilePath& session_dir,
const base::FilePath& web_sessions) const;
private:
OptimizedSession(ios::proto::WebStateListStorage metadata_storage,
std::vector<web::proto::WebStateStorage> storage);
explicit OptimizedSession(SessionWindowIOS* legacy_session);
// Helper adding an item to the current object from its legacy
// representation in `item`.
void AddItem(CRWSessionStorage* item);
// Helper adding a tab group to the current object from its legacy
// representation in `tab_group`.
void AddTabGroup(SessionTabGroup* tab_group);
ios::proto::WebStateListStorage metadata_storage_;
std::vector<web::proto::WebStateStorage> storage_;
};
// static
std::optional<OptimizedSession> OptimizedSession::FromLegacy(
SessionWindowIOS* legacy_session) {
return OptimizedSession(legacy_session);
}
// static
std::optional<OptimizedSession> OptimizedSession::FromPath(
const base::FilePath& session_dir) {
const base::FilePath session_path =
session_dir.Append(kSessionMetadataFilename);
ios::proto::WebStateListStorage metadata_storage;
if (!ParseProto(session_path, metadata_storage)) {
return std::nullopt;
}
const int count = metadata_storage.items_size();
std::vector<web::proto::WebStateStorage> storage;
storage.reserve(count);
for (int index = 0; index < count; ++index) {
const ios::proto::WebStateListItemStorage& item_storage =
metadata_storage.items(index);
const base::FilePath item_dir = session_dir.Append(
base::StringPrintf("%08x", item_storage.identifier()));
const base::FilePath item_path = item_dir.Append(kWebStateStorageFilename);
if (!ParseProto(item_path, storage.emplace_back())) {
return std::nullopt;
}
}
return OptimizedSession(std::move(metadata_storage), std::move(storage));
}
SessionWindowIOS* OptimizedSession::ToLegacy() const {
DCHECK_EQ(metadata_storage_.items_size(), static_cast<int>(storage_.size()));
const int count = metadata_storage_.items_size();
const int pinned_count = metadata_storage_.pinned_item_count();
NSMutableArray<CRWSessionStorage*>* items = [[NSMutableArray alloc] init];
for (int index = 0; index < count; ++index) {
const ios::proto::WebStateListItemStorage& item_storage =
metadata_storage_.items(index);
web::proto::WebStateStorage item_data_storage = storage_[index];
*item_data_storage.mutable_metadata() = item_storage.metadata();
const web::WebStateID identifier =
web::WebStateID::FromSerializedValue(item_storage.identifier());
CRWSessionStorage* item =
[[CRWSessionStorage alloc] initWithProto:item_data_storage
uniqueIdentifier:identifier
stableIdentifier:[[NSUUID UUID] UUIDString]];
if (index < pinned_count || item_storage.has_opener()) {
CRWSessionUserData* user_data = [[CRWSessionUserData alloc] init];
if (index < pinned_count) {
[user_data setObject:@YES forKey:kLegacyWebStateListPinnedStateKey];
}
if (item_storage.has_opener()) {
const ios::proto::OpenerStorage& opener_storage = item_storage.opener();
[user_data setObject:@(opener_storage.index())
forKey:kLegacyWebStateListOpenerIndexKey];
[user_data setObject:@(opener_storage.navigation_index())
forKey:kLegacyWebStateListOpenerNavigationIndexKey];
}
item.userData = user_data;
}
[items addObject:item];
}
NSUInteger selected_index = NSNotFound;
const int active_index = metadata_storage_.active_index();
if (0 <= active_index && active_index < count) {
selected_index = static_cast<NSUInteger>(active_index);
}
// Migrate tab groups.
NSMutableArray<SessionTabGroup*>* groups = [[NSMutableArray alloc] init];
for (int index = 0; index < metadata_storage_.groups_size(); ++index) {
const ios::proto::TabGroupStorage& group_storage =
metadata_storage_.groups(index);
SessionTabGroup* session_tab_group = [[SessionTabGroup alloc]
initWithRangeStart:group_storage.range().start()
rangeCount:group_storage.range().count()
title:base::SysUTF8ToNSString(group_storage.title())
colorId:static_cast<NSInteger>(group_storage.color())
collapsedState:group_storage.collapsed()
tabGroupId:tab_group_util::TabGroupIdFromStorage(
group_storage.tab_group_id())];
[groups addObject:session_tab_group];
}
return [[SessionWindowIOS alloc] initWithSessions:items
tabGroups:groups
selectedIndex:selected_index];
}
bool OptimizedSession::SaveTo(const base::FilePath& session_dir,
const base::FilePath& web_sessions) const {
DCHECK_EQ(metadata_storage_.items_size(), static_cast<int>(storage_.size()));
const int count = metadata_storage_.items_size();
// First write the individual WebState's data.
for (int index = 0; index < count; ++index) {
const ios::proto::WebStateListItemStorage& item_storage =
metadata_storage_.items(index);
const base::FilePath item_dir = session_dir.Append(
base::StringPrintf("%08x", item_storage.identifier()));
const base::FilePath item_path = item_dir.Append(kWebStateStorageFilename);
// Save the WebState data.
if (!WriteProto(item_path, storage_[index])) {
return false;
}
const base::FilePath item_native_data_path = web_sessions.Append(
base::StringPrintf("%08u", item_storage.identifier()));
// Copy the WebState WKWebView native data if it exists. It is okay if
// the copy fails, since loading the sessions accepts their absence.
if (FileExists(item_native_data_path)) {
std::ignore = ios::sessions::CopyFile(
item_native_data_path, item_dir.Append(kWebStateSessionFilename));
}
}
const base::FilePath session_path =
session_dir.Append(kSessionMetadataFilename);
// Save the session metadata.
if (!WriteProto(session_path, metadata_storage_)) {
return false;
}
return true;
}
OptimizedSession::OptimizedSession(
ios::proto::WebStateListStorage metadata_storage,
std::vector<web::proto::WebStateStorage> storage)
: metadata_storage_(std::move(metadata_storage)),
storage_(std::move(storage)) {}
OptimizedSession::OptimizedSession(SessionWindowIOS* legacy_session) {
metadata_storage_.set_active_index(legacy_session.selectedIndex);
for (CRWSessionStorage* legacy_item in legacy_session.sessions) {
AddItem(legacy_item);
}
for (SessionTabGroup* legacy_tab_group in legacy_session.tabGroups) {
AddTabGroup(legacy_tab_group);
}
}
void OptimizedSession::AddTabGroup(SessionTabGroup* legacy_tab_group) {
ios::proto::TabGroupStorage& group_storage = *metadata_storage_.add_groups();
ios::proto::RangeIndex& range = *group_storage.mutable_range();
range.set_start(legacy_tab_group.rangeStart);
range.set_count(legacy_tab_group.rangeCount);
group_storage.set_title(base::SysNSStringToUTF8(legacy_tab_group.title));
group_storage.set_color(
static_cast<ios::proto::TabGroupColorId>(legacy_tab_group.colorId));
group_storage.set_collapsed(legacy_tab_group.collapsedState);
tab_group_util::TabGroupIdForStorage(legacy_tab_group.tabGroupId,
*group_storage.mutable_tab_group_id());
}
void OptimizedSession::AddItem(CRWSessionStorage* legacy_item) {
ios::proto::WebStateListItemStorage& item = *metadata_storage_.add_items();
item.set_identifier(legacy_item.uniqueIdentifier.identifier());
// Serialize the item to protobuf message format, and move the metadata
// to the WebStateListStorage (since is is where the optimised format
// stores the WebState's metadata).
[legacy_item serializeToProto:storage_.emplace_back()];
DCHECK(storage_.back().has_metadata());
std::unique_ptr<web::proto::WebStateMetadataStorage> item_metadata(
storage_.back().release_metadata());
DCHECK(!storage_.back().has_metadata());
item_metadata->Swap(item.mutable_metadata());
DCHECK(item.has_metadata());
// The legacy format stores some WebStateList metadata in `item`.
CRWSessionUserData* user_data = legacy_item.userData;
if (user_data) {
NSNumber* opener_index = base::apple::ObjCCast<NSNumber>(
[user_data objectForKey:kLegacyWebStateListOpenerIndexKey]);
NSNumber* opener_navigation_index = base::apple::ObjCCast<NSNumber>(
[user_data objectForKey:kLegacyWebStateListOpenerNavigationIndexKey]);
if (opener_index && opener_navigation_index) {
ios::proto::OpenerStorage& opener_storage = *item.mutable_opener();
opener_storage.set_index([opener_index intValue]);
opener_storage.set_navigation_index([opener_navigation_index intValue]);
}
NSNumber* is_pinned = base::apple::ObjCCast<NSNumber>(
[user_data objectForKey:kLegacyWebStateListPinnedStateKey]);
if (is_pinned && [is_pinned boolValue]) {
metadata_storage_.set_pinned_item_count(
metadata_storage_.pinned_item_count() + 1);
}
}
// Check the class invariants.
DCHECK_EQ(metadata_storage_.items_size(), static_cast<int>(storage_.size()));
DCHECK_LE(metadata_storage_.pinned_item_count(),
metadata_storage_.items_size());
}
// Migrates session stored in `from` in legacy format to `dest` in optimized
// format. The web sessions files (if present) are stored in `web_sessions`.
// Returns whether the migration status.
MigrationResult MigrateSessionToOptimizedInternal(
const base::FilePath& from,
const base::FilePath& dest,
const base::FilePath& web_sessions,
int32_t next_session_identifier) {
const base::FilePath legacy_path = from.Append(kLegacySessionFilename);
if (!FileExists(legacy_path)) {
return MigrationResult::Success(next_session_identifier);
}
SessionWindowIOS* legacy = ReadSessionWindow(legacy_path);
if (!legacy) {
return MigrationResult::Failure();
}
// If the identifiers loaded from disk are invalid, assign new identifiers.
for (CRWSessionStorage* storage in legacy.sessions) {
if (!storage.uniqueIdentifier.valid()) {
storage.uniqueIdentifier =
web::WebStateID::FromSerializedValue(next_session_identifier++);
}
}
std::optional<OptimizedSession> optimized =
OptimizedSession::FromLegacy(legacy);
if (!optimized || !optimized->SaveTo(dest, web_sessions)) {
return MigrationResult::Failure();
}
return MigrationResult::Success(next_session_identifier);
}
// Migrates session stored in `from` in optimized format to `dest` in legacy
// format. The web sessions files (if present) are stored in `web_sessions`.
// Returns whether the migration status.
MigrationResult MigrateSessionToLegacyInternal(
const base::FilePath& from,
const base::FilePath& dest,
const base::FilePath& web_sessions,
int32_t next_session_identifier) {
const base::FilePath metadata_path = from.Append(kSessionMetadataFilename);
if (!FileExists(metadata_path)) {
return MigrationResult::Success(next_session_identifier);
}
std::optional<OptimizedSession> optimized = OptimizedSession::FromPath(from);
if (!optimized) {
return MigrationResult::Failure();
}
SessionWindowIOS* legacy = optimized->ToLegacy();
DCHECK(legacy);
// Write the legacy session to destination.
if (!WriteSessionWindow(dest.Append(kLegacySessionFilename), legacy)) {
return MigrationResult::Failure();
}
// Migrate the web session files if possible.
for (CRWSessionStorage* item in legacy.sessions) {
const base::FilePath item_dir = from.Append(
base::StringPrintf("%08x", item.uniqueIdentifier.identifier()));
const base::FilePath item_native_data_path =
item_dir.Append(kWebStateSessionFilename);
// Copy the WebState WKWebView native data if it exists. It is okay if
// the copy fails, since loading the sessions accepts their absence.
if (FileExists(item_native_data_path)) {
std::ignore = ios::sessions::CopyFile(
item_native_data_path,
web_sessions.Append(
base::StringPrintf("%08u", item.uniqueIdentifier.identifier())));
}
}
return MigrationResult::Success(next_session_identifier);
}
// Helper for MigrateSessionsInPathsToOptimized(...) that migrate the data
// but performs no cleanup. It stops at the first failure.
MigrationResult MigrateSessionsInPathsToOptimizedNoCleanup(
const std::vector<base::FilePath>& paths,
int32_t next_session_identifier) {
for (const base::FilePath& path : paths) {
const base::FilePath from_dir = path.Append(kLegacySessionsDirname);
const base::FilePath dest_dir = path.Append(kSessionRestorationDirname);
const base::FilePath sessions = path.Append(kLegacyWebSessionsDirname);
const int file_types = base::FileEnumerator::DIRECTORIES;
base::FileEnumerator iter(from_dir, false, file_types);
for (base::FilePath name = iter.Next(); !name.empty(); name = iter.Next()) {
const base::FilePath basename = name.BaseName();
const MigrationResult result = MigrateSessionToOptimizedInternal(
from_dir.Append(basename), dest_dir.Append(basename), sessions,
next_session_identifier);
if (result.status != MigrationResult::Status::kSuccess) {
return MigrationResult::Failure();
}
next_session_identifier = result.next_session_identifier;
}
}
return MigrationResult::Success(next_session_identifier);
}
// Helper for MigrateSessionsInPathsToLegacy(...) that migrate the data
// but performs no cleanup. It stops at the first failure.
MigrationResult MigrateSessionsInPathsToLegacyNoCleanup(
const std::vector<base::FilePath>& paths,
int32_t next_session_identifier) {
for (const base::FilePath& path : paths) {
const base::FilePath from_dir = path.Append(kSessionRestorationDirname);
const base::FilePath dest_dir = path.Append(kLegacySessionsDirname);
const base::FilePath sessions = path.Append(kLegacyWebSessionsDirname);
const int file_types = base::FileEnumerator::DIRECTORIES;
base::FileEnumerator iter(from_dir, false, file_types);
for (base::FilePath name = iter.Next(); !name.empty(); name = iter.Next()) {
const base::FilePath basename = name.BaseName();
const MigrationResult result = MigrateSessionToLegacyInternal(
from_dir.Append(basename), dest_dir.Append(basename), sessions,
next_session_identifier);
if (result.status != MigrationResult::Status::kSuccess) {
return MigrationResult::Failure();
}
next_session_identifier = result.next_session_identifier;
}
}
return MigrationResult::Success(next_session_identifier);
}
// Deletes optimized session directories in `paths`.
void DeleteOptimizedSessions(const std::vector<base::FilePath>& paths) {
for (const base::FilePath& path : paths) {
const base::FilePath optimized = path.Append(kSessionRestorationDirname);
std::ignore = DeleteRecursively(optimized);
}
}
// Deletes legacy session directories in `paths` taking care of leaving
// any unrelated content unaffected.
void DeleteLegacySessions(const std::vector<base::FilePath>& paths) {
for (const base::FilePath& path : paths) {
const base::FilePath legacy = path.Append(kLegacySessionsDirname);
const int file_types = base::FileEnumerator::DIRECTORIES;
base::FileEnumerator iter(legacy, false, file_types);
for (base::FilePath name = iter.Next(); !name.empty(); name = iter.Next()) {
std::ignore = DeleteRecursively(name);
}
if (ios::sessions::DirectoryEmpty(legacy)) {
std::ignore = DeleteRecursively(legacy);
}
const base::FilePath sessions = path.Append(kLegacyWebSessionsDirname);
std::ignore = DeleteRecursively(sessions);
}
}
} // namespace
MigrationResult MigrateSessionsInPathsToOptimized(
const std::vector<base::FilePath>& paths,
int32_t next_session_identifier) {
// Try to perform the migration, stopping at the first failure.
const MigrationResult result = MigrateSessionsInPathsToOptimizedNoCleanup(
paths, next_session_identifier);
// Cleanup after the migration by deleting either the partially migrated
// data (in case of failure) or original data (in case of success).
switch (result.status) {
case MigrationResult::Status::kSuccess:
// The data has been successfully migrated to optimized storage,
// delete the legacy storage (including the cache of WKWebView
// native session data).
DeleteLegacySessions(paths);
break;
case MigrationResult::Status::kFailure:
// The migration to optimized format failed, delete any data that
// may have been written in the optimised storage directory.
DeleteOptimizedSessions(paths);
break;
}
return result;
}
MigrationResult MigrateSessionsInPathsToLegacy(
const std::vector<base::FilePath>& paths,
int32_t next_session_identifier) {
// Try to perform the migration, stopping at the first failure.
const MigrationResult result =
MigrateSessionsInPathsToLegacyNoCleanup(paths, next_session_identifier);
// Cleanup after the migration by deleting either the partially migrated
// data (in case of failure) or original data (in case of success).
switch (result.status) {
case MigrationResult::Status::kSuccess:
// The data has been successfully migrated to legacy storage,
// delete the optimized storage.
DeleteOptimizedSessions(paths);
break;
case MigrationResult::Status::kFailure:
// The migration to legacy format failed, delete any data that
// may have been written in the legacy storage directory. Also
// delete the cache of WKWebView native session data.
DeleteLegacySessions(paths);
break;
}
return result;
}
// Comparison operators for testing.
bool operator==(const MigrationResult& lhs, const MigrationResult& rhs) {
switch (lhs.status) {
case MigrationResult::Status::kSuccess:
return rhs.status == MigrationResult::Status::kSuccess &&
rhs.next_session_identifier == lhs.next_session_identifier;
case MigrationResult::Status::kFailure:
return rhs.status == MigrationResult::Status::kFailure;
}
}
bool operator!=(const MigrationResult& lhs, const MigrationResult& rhs) {
switch (lhs.status) {
case MigrationResult::Status::kSuccess:
return rhs.status != MigrationResult::Status::kSuccess ||
rhs.next_session_identifier != lhs.next_session_identifier;
case MigrationResult::Status::kFailure:
return rhs.status != MigrationResult::Status::kFailure;
}
}
// Insertion operator for testing.
std::ostream& operator<<(std::ostream& stream, const MigrationResult& result) {
switch (result.status) {
case MigrationResult::Status::kSuccess:
return stream << "MigrationResult::Status::Success("
<< result.next_session_identifier << ")";
case MigrationResult::Status::kFailure:
return stream << "MigrationResult::Status::Failure()";
}
}
} // namespace ios::sessions