import type { LaunchablePhoneAppId } from '@/types/apps' import type { ReorderDirection } from '@/utils/keyboard' export const HOME_DOCK_CAPACITY = 4 export const HOME_GRID_COLUMNS = 4 export const HOME_GRID_ROWS = 6 export const HOME_GRID_PAGE_SIZE = HOME_GRID_COLUMNS * HOME_GRID_ROWS export const HOME_FOLDER_PAGE_SIZE = 9 export const HOME_FOLDER_NAME_MAX_LENGTH = 32 export const MAX_HOME_GRID_PAGES = 5 export const HOME_LAYOUT_VERSION = 5 const LEGACY_HOME_GRID_PAGE_SIZE = 20 export type HomeArea = 'dock' | 'grid' export type HomeFolder = { apps: LaunchablePhoneAppId[] id: string name: string type: 'folder' } export type HomeItem = HomeFolder | LaunchablePhoneAppId export type HomeSlot = HomeItem | null export type HomeLayout = { dock: HomeSlot[] grid: HomeSlot[] hidden: LaunchablePhoneAppId[] version: typeof HOME_LAYOUT_VERSION } export type HomeGridPageCapacities = readonly number[] export function isHomeFolder(value: unknown): value is HomeFolder { if (!value || typeof value !== 'object') return false const folder = value as Partial return ( folder.type === 'folder' && typeof folder.id === 'string' && typeof folder.name === 'string' && Array.isArray(folder.apps) ) } function isPersistableAppId(value: unknown): value is LaunchablePhoneAppId { return typeof value === 'string' && /^[a-z0-9][a-z0-9._-]{1,63}$/.test(value) } function isPersistableFolderId(value: unknown): value is string { return typeof value === 'string' && /^folder-[a-z0-9-]{6,80}$/.test(value) } function readAppIds( value: unknown, availableIds: Set, ): LaunchablePhoneAppId[] { if (!Array.isArray(value)) return [] const ids: LaunchablePhoneAppId[] = [] for (const valueId of value) { if ( typeof valueId === 'string' && availableIds.has(valueId as LaunchablePhoneAppId) && !ids.includes(valueId as LaunchablePhoneAppId) ) { ids.push(valueId as LaunchablePhoneAppId) } } return ids } function createSlots(length: number): HomeSlot[] { return Array.from({ length }, () => null) } function getGridCapacity(itemCount: number): number { return Math.max( HOME_GRID_PAGE_SIZE, Math.ceil(itemCount / HOME_GRID_PAGE_SIZE) * HOME_GRID_PAGE_SIZE, ) } function cloneItem(item: HomeSlot): HomeSlot { return isHomeFolder(item) ? { ...item, apps: [...item.apps] } : item } function cloneLayout(layout: HomeLayout): HomeLayout { return { dock: layout.dock.map(cloneItem), grid: layout.grid.map(cloneItem), hidden: [...layout.hidden], version: HOME_LAYOUT_VERSION, } } function normalizeFolder(folder: HomeFolder): HomeSlot { if (folder.apps.length === 0) return null if (folder.apps.length === 1) return folder.apps[0] return folder } function readItem( value: unknown, availableIds: Set, folderIds: Set, allowFolders: boolean, ): HomeSlot { if ( typeof value === 'string' && availableIds.has(value as LaunchablePhoneAppId) ) { return value as LaunchablePhoneAppId } if ( !allowFolders || !isHomeFolder(value) || !isPersistableFolderId(value.id) || folderIds.has(value.id) ) { return null } const apps = readAppIds(value.apps, availableIds) if (apps.length === 0) return null if (apps.length === 1) return apps[0] folderIds.add(value.id) return { apps, id: value.id, name: value.name.trim().slice(0, HOME_FOLDER_NAME_MAX_LENGTH), type: 'folder', } } function readSlots( value: unknown, availableIds: Set, length: number, folderIds: Set, allowFolders: boolean, ): HomeSlot[] { const slots = createSlots(length) if (!Array.isArray(value)) return slots for (let index = 0; index < Math.min(value.length, length); index += 1) { slots[index] = readItem(value[index], availableIds, folderIds, allowFolders) } return slots } function migrateLegacyGrid( value: unknown, availableIds: Set, ): HomeSlot[] { if (!Array.isArray(value)) return createSlots(HOME_GRID_PAGE_SIZE) const legacyLength = Math.min( value.length, LEGACY_HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES, ) const pageCount = Math.max( 1, Math.ceil(legacyLength / LEGACY_HOME_GRID_PAGE_SIZE), ) const slots = createSlots(pageCount * HOME_GRID_PAGE_SIZE) for (let index = 0; index < legacyLength; index += 1) { const valueId = value[index] if ( typeof valueId !== 'string' || !availableIds.has(valueId as LaunchablePhoneAppId) ) { continue } const page = Math.floor(index / LEGACY_HOME_GRID_PAGE_SIZE) const pageIndex = index % LEGACY_HOME_GRID_PAGE_SIZE slots[page * HOME_GRID_PAGE_SIZE + pageIndex] = valueId as LaunchablePhoneAppId } return slots } function placeInFirstEmptySlot(slots: HomeSlot[], item: HomeItem): void { const emptyIndex = slots.indexOf(null) if (emptyIndex !== -1) { slots[emptyIndex] = cloneItem(item) return } slots.push(...createSlots(HOME_GRID_PAGE_SIZE)) slots[slots.length - HOME_GRID_PAGE_SIZE] = cloneItem(item) } function insertIntoSlot( slots: HomeSlot[], targetIndex: number, item: HomeItem, ): HomeSlot { if (slots[targetIndex] === null) { slots[targetIndex] = cloneItem(item) return null } const emptyAfter = slots.indexOf(null, targetIndex + 1) if (emptyAfter !== -1) { for (let index = emptyAfter; index > targetIndex; index -= 1) { slots[index] = slots[index - 1] } slots[targetIndex] = cloneItem(item) return null } const emptyBefore = slots.lastIndexOf(null, targetIndex - 1) if (emptyBefore !== -1) { for (let index = emptyBefore; index < targetIndex; index += 1) { slots[index] = slots[index + 1] } slots[targetIndex] = cloneItem(item) return null } const displacedItem = cloneItem(slots.at(-1) ?? null) for (let index = slots.length - 1; index > targetIndex; index -= 1) { slots[index] = slots[index - 1] } slots[targetIndex] = cloneItem(item) return displacedItem } function itemContainsApp(item: HomeSlot, appId: LaunchablePhoneAppId): boolean { return isHomeFolder(item) ? item.apps.includes(appId) : item === appId } function folderLocation( layout: HomeLayout, folderId: string, ): { area: HomeArea; index: number } | null { for (const area of ['grid', 'dock'] as const) { const index = layout[area].findIndex( (item) => isHomeFolder(item) && item.id === folderId, ) if (index !== -1) return { area, index } } return null } export function getHomeFolder( layout: HomeLayout, folderId: string, ): HomeFolder | null { const location = folderLocation(layout, folderId) if (!location) return null const item = layout[location.area][location.index] return isHomeFolder(item) ? item : null } function readPageItems(slots: HomeSlot[], pageStart: number): HomeItem[] { return slots .slice(pageStart, pageStart + HOME_GRID_PAGE_SIZE) .filter((item): item is HomeItem => item !== null) .map((item) => cloneItem(item) as HomeItem) } function writePageItems( slots: HomeSlot[], pageStart: number, items: HomeItem[], ): void { for (let offset = 0; offset < HOME_GRID_PAGE_SIZE; offset += 1) { slots[pageStart + offset] = cloneItem(items[offset] ?? null) } } function compactGridPages(slots: HomeSlot[]): HomeSlot[] { const compacted = slots.map(cloneItem) for ( let pageStart = 0; pageStart < compacted.length; pageStart += HOME_GRID_PAGE_SIZE ) { writePageItems(compacted, pageStart, readPageItems(compacted, pageStart)) } return compacted } type IndexedHomeItem = { item: HomeItem sourceIndex: number } function gridPageCapacity( capacities: HomeGridPageCapacities, page: number, ): number { const capacity = capacities[page - 1] return Number.isFinite(capacity) ? Math.max(0, Math.min(HOME_GRID_PAGE_SIZE, Math.trunc(capacity))) : HOME_GRID_PAGE_SIZE } function distributeGridPages( slots: readonly HomeSlot[], capacities: HomeGridPageCapacities, minimumPageCount: number, ): IndexedHomeItem[][] | null { const persistedPageCount = Math.max( 1, Math.ceil(slots.length / HOME_GRID_PAGE_SIZE), ) const pages: IndexedHomeItem[][] = [] let incoming: IndexedHomeItem[] = [] let pageCount = Math.max(persistedPageCount, minimumPageCount) for (let page = 1; page <= pageCount; page += 1) { const pageStart = (page - 1) * HOME_GRID_PAGE_SIZE const persisted = slots .slice(pageStart, pageStart + HOME_GRID_PAGE_SIZE) .flatMap((item, offset) => item === null ? [] : [ { item: cloneItem(item) as HomeItem, sourceIndex: pageStart + offset, }, ], ) const available = [...incoming, ...persisted] const capacity = gridPageCapacity(capacities, page) pages.push(available.slice(0, capacity)) incoming = available.slice(capacity) if ( page === pageCount && incoming.length && pageCount < MAX_HOME_GRID_PAGES ) { pageCount += 1 } } return incoming.length ? null : pages } function serializeGridPages(pages: readonly IndexedHomeItem[][]): HomeSlot[] { const slots = createSlots(Math.max(1, pages.length) * HOME_GRID_PAGE_SIZE) for (const [pageIndex, entries] of pages.entries()) { const pageStart = pageIndex * HOME_GRID_PAGE_SIZE for (const [offset, entry] of entries.entries()) { slots[pageStart + offset] = cloneItem(entry.item) } } return slots } export function moveHomeAppToGridPage( layout: HomeLayout, from: HomeArea, sourceIndex: number, targetPage: number, targetOffset: number, capacities: HomeGridPageCapacities = [], ): HomeLayout { const sourceSlots = layout[from] const sourceItem = sourceSlots[sourceIndex] if ( sourceItem === null || sourceItem === undefined || sourceIndex < 0 || sourceIndex >= sourceSlots.length || !Number.isInteger(targetPage) || targetPage < 1 || targetPage > MAX_HOME_GRID_PAGES || !Number.isFinite(targetOffset) || gridPageCapacity(capacities, targetPage) === 0 ) { return layout } const pages = distributeGridPages(layout.grid, capacities, targetPage) if (!pages) return layout let sourcePage = -1 if (from === 'grid') { for (const [pageIndex, entries] of pages.entries()) { const entryIndex = entries.findIndex( (entry) => entry.sourceIndex === sourceIndex, ) if (entryIndex !== -1) { sourcePage = pageIndex + 1 entries.splice(entryIndex, 1) break } } if (sourcePage === -1) return layout } while (pages.length < targetPage) pages.push([]) const targetEntries = pages[targetPage - 1] const requestedOffset = Math.max(0, Math.trunc(targetOffset)) targetEntries.splice(Math.min(requestedOffset, targetEntries.length), 0, { item: cloneItem(sourceItem) as HomeItem, sourceIndex, }) for (let page = targetPage; page <= pages.length; page += 1) { const entries = pages[page - 1] const capacity = gridPageCapacity(capacities, page) if (entries.length <= capacity) continue const overflow = entries.splice(capacity) if (page >= MAX_HOME_GRID_PAGES) return layout if (!pages[page]) pages.push([]) pages[page].unshift(...overflow) } const next = cloneLayout(layout) next.grid = serializeGridPages(pages) if (from === 'dock') next.dock[sourceIndex] = null if ( JSON.stringify(next.grid) === JSON.stringify(layout.grid) && JSON.stringify(next.dock) === JSON.stringify(layout.dock) ) { return layout } return next } export function createDefaultHomeLayout( installedIds: LaunchablePhoneAppId[], defaultGridIds: LaunchablePhoneAppId[], defaultDockIds: LaunchablePhoneAppId[], ): HomeLayout { const installed = new Set(installedIds) const gridIds = defaultGridIds.filter((id) => installed.has(id)) const grid = createSlots(getGridCapacity(gridIds.length)) for (let index = 0; index < gridIds.length; index += 1) { grid[index] = gridIds[index] } const dock = createSlots(HOME_DOCK_CAPACITY) for (const [index, id] of defaultDockIds .filter((id) => installed.has(id)) .slice(0, HOME_DOCK_CAPACITY) .entries()) { dock[index] = id } return { dock, grid, hidden: [], version: HOME_LAYOUT_VERSION } } export function parseHomeLayout( value: unknown, defaults: HomeLayout, installedIds: LaunchablePhoneAppId[], ): HomeLayout { if (!value || typeof value !== 'object') return defaults const source = value as Partial> const availableIds = new Set(installedIds) if (source.version === 3 || source.version === 4 || source.version === 5) { for (const collection of [source.dock, source.grid, source.hidden]) { if (!Array.isArray(collection)) continue for (const item of collection) { if (isPersistableAppId(item)) availableIds.add(item) if (source.version === 5 && isHomeFolder(item)) { for (const appId of item.apps) { if (isPersistableAppId(appId)) availableIds.add(appId) } } } } } const hidden = readAppIds(source.hidden, availableIds) const hiddenIds = new Set(hidden) const isLegacyVersionedLayout = source.version === 2 || source.version === 3 const persistedGridLength = Array.isArray(source.grid) ? isLegacyVersionedLayout ? Math.ceil( Math.min( source.grid.length, LEGACY_HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES, ) / LEGACY_HOME_GRID_PAGE_SIZE, ) * HOME_GRID_PAGE_SIZE : Math.min(source.grid.length, HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES) : 0 const gridLength = Math.max( defaults.grid.length, getGridCapacity(persistedGridLength), ) let grid: HomeSlot[] let dock: HomeSlot[] if (isLegacyVersionedLayout) { grid = migrateLegacyGrid(source.grid, availableIds) if (grid.length < gridLength) { grid.push(...createSlots(gridLength - grid.length)) } dock = readSlots( source.dock, availableIds, HOME_DOCK_CAPACITY, new Set(), false, ) } else if (source.version === 4 || source.version === 5) { const folderIds = new Set() const allowFolders = source.version === 5 grid = readSlots( source.grid, availableIds, gridLength, folderIds, allowFolders, ) dock = readSlots( source.dock, availableIds, HOME_DOCK_CAPACITY, folderIds, allowFolders, ) } else { grid = createSlots(gridLength) for (const id of readAppIds(source.grid, availableIds)) { placeInFirstEmptySlot(grid, id) } dock = createSlots(HOME_DOCK_CAPACITY) for (const [index, id] of readAppIds(source.dock, availableIds) .slice(0, HOME_DOCK_CAPACITY) .entries()) { dock[index] = id } } const removeHidden = (item: HomeSlot): HomeSlot => { if (typeof item === 'string') return hiddenIds.has(item) ? null : item if (!isHomeFolder(item)) return null return normalizeFolder({ ...item, apps: item.apps.filter((appId) => !hiddenIds.has(appId)), }) } grid = compactGridPages(grid.map(removeHidden)) dock = dock.map(removeHidden) const placedIds = new Set(hidden) for (const item of [...grid, ...dock]) { if (typeof item === 'string') placedIds.add(item) if (isHomeFolder(item)) { for (const appId of item.apps) placedIds.add(appId) } } for (const item of defaults.grid) { if (typeof item === 'string' && !placedIds.has(item)) { placeInFirstEmptySlot(grid, item) placedIds.add(item) } } return { dock: dock.map(cloneItem), grid: compactGridPages(grid), hidden, version: HOME_LAYOUT_VERSION, } } export function removeHomeApp( layout: HomeLayout, appId: LaunchablePhoneAppId, ): HomeLayout { const removeFromItem = (item: HomeSlot): HomeSlot => { if (item === appId) return null if (!isHomeFolder(item)) return cloneItem(item) return normalizeFolder({ ...item, apps: item.apps.filter((folderAppId) => folderAppId !== appId), }) } return { dock: layout.dock.map(removeFromItem), grid: compactGridPages(layout.grid.map(removeFromItem)), hidden: layout.hidden.includes(appId) ? [...layout.hidden] : [...layout.hidden, appId], version: HOME_LAYOUT_VERSION, } } export function restoreHomeApp( layout: HomeLayout, appId: LaunchablePhoneAppId, ): HomeLayout { if ( layout.grid.some((item) => itemContainsApp(item, appId)) || layout.dock.some((item) => itemContainsApp(item, appId)) ) { return layout } const grid = layout.grid.map(cloneItem) placeInFirstEmptySlot(grid, appId) return { dock: layout.dock.map(cloneItem), grid: compactGridPages(grid), hidden: layout.hidden.filter((id) => id !== appId), version: HOME_LAYOUT_VERSION, } } export function addHomePage(layout: HomeLayout): HomeLayout { if (layout.grid.length >= HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES) { return layout } return { dock: layout.dock.map(cloneItem), grid: [...layout.grid.map(cloneItem), ...createSlots(HOME_GRID_PAGE_SIZE)], hidden: [...layout.hidden], version: HOME_LAYOUT_VERSION, } } export function deleteHomePage(layout: HomeLayout, page: number): HomeLayout { const pageCount = Math.ceil(layout.grid.length / HOME_GRID_PAGE_SIZE) if (pageCount <= 1 || page < 1 || page > pageCount) return layout const pageStart = (page - 1) * HOME_GRID_PAGE_SIZE const grid = layout.grid.map(cloneItem) const removedItems = grid .splice(pageStart, HOME_GRID_PAGE_SIZE) .filter((item): item is HomeItem => item !== null) if (removedItems.length > grid.filter((item) => item === null).length) { return layout } for (const item of removedItems) placeInFirstEmptySlot(grid, item) return { dock: layout.dock.map(cloneItem), grid: compactGridPages(grid), hidden: [...layout.hidden], version: HOME_LAYOUT_VERSION, } } export function moveHomeApp( layout: HomeLayout, from: HomeArea, sourceIndex: number, to: HomeArea, targetIndex: number, ): HomeLayout { const sourceSlots = layout[from] const item = sourceSlots[sourceIndex] const maximumTargetIndex = to === 'grid' ? HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES : HOME_DOCK_CAPACITY if ( item === null || item === undefined || sourceIndex < 0 || sourceIndex >= sourceSlots.length || targetIndex < 0 || targetIndex >= maximumTargetIndex || (from === to && sourceIndex === targetIndex) ) { return layout } if (to === 'grid') { return moveHomeAppToGridPage( layout, from, sourceIndex, Math.floor(targetIndex / HOME_GRID_PAGE_SIZE) + 1, targetIndex % HOME_GRID_PAGE_SIZE, ) } const next = cloneLayout(layout) const source = next[from] const target = next[to] if (from === to) { source[sourceIndex] = null insertIntoSlot(source, targetIndex, item) return next } source[sourceIndex] = insertIntoSlot(target, targetIndex, item) next.grid = compactGridPages(next.grid) return next } export function createHomeFolder( layout: HomeLayout, from: HomeArea, sourceIndex: number, to: HomeArea, targetIndex: number, folderId: string, name: string, ): HomeLayout { if (!isPersistableFolderId(folderId) || folderLocation(layout, folderId)) { return layout } const sourceItem = layout[from][sourceIndex] const targetItem = layout[to][targetIndex] if ( typeof sourceItem !== 'string' || typeof targetItem !== 'string' || (from === to && sourceIndex === targetIndex) ) { return layout } const next = cloneLayout(layout) next[from][sourceIndex] = null next[to][targetIndex] = { apps: [targetItem, sourceItem], id: folderId, name: name.trim().slice(0, HOME_FOLDER_NAME_MAX_LENGTH), type: 'folder', } next.grid = compactGridPages(next.grid) return next } export function addHomeAppToFolder( layout: HomeLayout, from: HomeArea, sourceIndex: number, folderId: string, ): HomeLayout { const sourceItem = layout[from][sourceIndex] const location = folderLocation(layout, folderId) if (typeof sourceItem !== 'string' || !location) return layout if (location.area === from && location.index === sourceIndex) return layout const next = cloneLayout(layout) const folder = next[location.area][location.index] if (!isHomeFolder(folder)) return layout next[from][sourceIndex] = null folder.apps.push(sourceItem) next.grid = compactGridPages(next.grid) return next } export function renameHomeFolder( layout: HomeLayout, folderId: string, name: string, ): HomeLayout { const location = folderLocation(layout, folderId) if (!location) return layout const next = cloneLayout(layout) const folder = next[location.area][location.index] if (!isHomeFolder(folder)) return layout const nextName = name.trim().slice(0, HOME_FOLDER_NAME_MAX_LENGTH) if (folder.name === nextName) return layout folder.name = nextName return next } export function moveHomeFolderApp( layout: HomeLayout, folderId: string, sourceIndex: number, targetIndex: number, ): HomeLayout { const location = folderLocation(layout, folderId) if (!location) return layout const folder = layout[location.area][location.index] if ( !isHomeFolder(folder) || sourceIndex < 0 || sourceIndex >= folder.apps.length || targetIndex < 0 || targetIndex >= folder.apps.length || sourceIndex === targetIndex ) { return layout } const next = cloneLayout(layout) const nextFolder = next[location.area][location.index] if (!isHomeFolder(nextFolder)) return layout const sourceApp = nextFolder.apps[sourceIndex] nextFolder.apps[sourceIndex] = nextFolder.apps[targetIndex] nextFolder.apps[targetIndex] = sourceApp return next } export function extractHomeFolderApp( layout: HomeLayout, folderId: string, sourceIndex: number, to: HomeArea, targetIndex: number, ): HomeLayout { const location = folderLocation(layout, folderId) const target = layout[to][targetIndex] if ( !location || target !== null || (location.area === to && location.index === targetIndex) ) { return layout } const folder = layout[location.area][location.index] if ( !isHomeFolder(folder) || sourceIndex < 0 || sourceIndex >= folder.apps.length ) { return layout } const next = cloneLayout(layout) const nextFolder = next[location.area][location.index] if (!isHomeFolder(nextFolder)) return layout const [appId] = nextFolder.apps.splice(sourceIndex, 1) next[to][targetIndex] = appId next[location.area][location.index] = normalizeFolder(nextFolder) next.grid = compactGridPages(next.grid) return next } export function homeKeyboardTarget( layout: HomeLayout, area: HomeArea, sourceIndex: number, direction: ReorderDirection, ): number | null { const source = layout[area] if (!source[sourceIndex]) return null if (area === 'dock') { if (direction !== 'left' && direction !== 'right') return null const targetIndex = sourceIndex + (direction === 'left' ? -1 : 1) return targetIndex >= 0 && targetIndex < source.length ? targetIndex : null } const column = sourceIndex % HOME_GRID_COLUMNS if (direction === 'left' && column === 0) return null if (direction === 'right' && column === HOME_GRID_COLUMNS - 1) return null const deltas: Record = { down: HOME_GRID_COLUMNS, left: -1, right: 1, up: -HOME_GRID_COLUMNS, } const targetIndex = sourceIndex + deltas[direction] return targetIndex >= 0 && targetIndex < source.length ? targetIndex : null }