Merge branch 'dev' into feature/funk

# Conflicts:
#	frontend/src/config/apps.test.ts
#	frontend/src/config/apps.ts
#	frontend/src/stores/phone.ts
#	frontend/testserver/index.cjs
#	sky_phone/config/locales/en.lua
#	sky_phone/source/html/index.html
#	sky_phone/source/server/db_migrate.lua
This commit is contained in:
DerEchteAlec
2026-08-07 16:06:51 +02:00
50 changed files with 6108 additions and 164 deletions
+23
View File
@@ -0,0 +1,23 @@
export function copyText(value: string): boolean {
const activeElement =
document.activeElement instanceof HTMLElement
? document.activeElement
: undefined
const textarea = document.createElement('textarea')
textarea.value = value
textarea.readOnly = true
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange(0, value.length)
try {
return document.execCommand('copy')
} finally {
textarea.remove()
activeElement?.focus({ preventScroll: true })
}
}
+166
View File
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest'
import {
createDefaultHomeLayout,
HOME_GRID_PAGE_SIZE,
moveHomeApp,
parseHomeLayout,
removeHomeApp,
restoreHomeApp,
type HomeLayout,
} from '@/utils/homeLayout'
const installed = ['phone', 'messages', 'mail', 'clock', 'notes'] as const
const defaults = createDefaultHomeLayout(
[...installed],
['phone', 'messages', 'mail', 'clock', 'notes'],
['phone', 'messages', 'clock'],
)
describe('home layout', () => {
it('uses fixed grid and dock slots for the registry arrangement', () => {
const layout = parseHomeLayout(undefined, defaults, [...installed])
expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE)
expect(layout.grid.slice(0, 6)).toEqual([
'phone',
'messages',
'mail',
'clock',
'notes',
null,
])
expect(layout.dock).toEqual(['phone', 'messages', 'clock', null])
expect(layout.version).toBe(2)
})
it('migrates compact persisted arrays and appends newly installed apps', () => {
const layout = parseHomeLayout(
{
dock: ['messages', 'invalid', 'messages'],
grid: ['mail'],
hidden: ['phone'],
},
defaults,
[...installed],
)
expect(layout.dock).toEqual(['messages', null, null, null])
expect(layout.grid.slice(0, 4)).toEqual(['mail', 'clock', 'notes', null])
expect(layout.hidden).toEqual(['phone'])
expect(layout.version).toBe(2)
})
it('preserves explicit gaps in versioned layouts', () => {
const grid: HomeLayout['grid'] = Array.from(
{ length: HOME_GRID_PAGE_SIZE },
() => null,
)
grid[0] = 'phone'
grid[7] = 'mail'
const layout = parseHomeLayout(
{
dock: ['messages', null, 'clock', null],
grid,
hidden: ['notes'],
version: 2,
},
defaults,
[...installed],
)
expect(layout.grid[0]).toBe('phone')
expect(layout.grid[1]).toBeNull()
expect(layout.grid[7]).toBe('mail')
expect(layout.dock).toEqual(['messages', null, 'clock', null])
})
it('preserves independently positioned shortcuts for the same app', () => {
const grid: HomeLayout['grid'] = Array.from(
{ length: HOME_GRID_PAGE_SIZE },
() => null,
)
grid[0] = 'phone'
grid[5] = 'phone'
const layout = parseHomeLayout(
{
dock: ['phone', null, null, null],
grid,
hidden: [],
version: 2,
},
defaults,
[...installed],
)
expect(layout.grid[0]).toBe('phone')
expect(layout.grid[5]).toBe('phone')
expect(layout.dock[0]).toBe('phone')
})
it('moves to an exact empty slot without compacting other apps', () => {
const moved = moveHomeApp(defaults, 'grid', 2, 'grid', 12)
expect(moved.grid[2]).toBeNull()
expect(moved.grid[12]).toBe('mail')
expect(moved.grid[0]).toBe('phone')
expect(moved.grid[4]).toBe('notes')
})
it('shifts occupied grid slots instead of replacing their apps', () => {
const reordered = moveHomeApp(defaults, 'grid', 2, 'grid', 0)
expect(reordered.grid.slice(0, 5)).toEqual([
'mail',
'phone',
'messages',
'clock',
'notes',
])
})
it('shifts an occupied dock slot toward its gap', () => {
const docked = moveHomeApp(defaults, 'grid', 2, 'dock', 2)
expect(docked.dock).toEqual(['phone', 'messages', 'mail', 'clock'])
expect(docked.grid[2]).toBeNull()
})
it('moves an app displaced from a full dock into the source slot', () => {
const layout: HomeLayout = {
...defaults,
dock: ['phone', 'messages', 'clock', 'notes'],
}
const docked = moveHomeApp(layout, 'grid', 2, 'dock', 1)
expect(docked.dock).toEqual(['phone', 'mail', 'messages', 'clock'])
expect(docked.grid[2]).toBe('notes')
})
it('moves shortcuts between the dock and grid independently', () => {
const movedToGrid = moveHomeApp(defaults, 'dock', 0, 'grid', 5)
expect(movedToGrid.dock[0]).toBeNull()
expect(movedToGrid.grid[0]).toBe('phone')
expect(movedToGrid.grid[5]).toBe('phone')
const movedToDock = moveHomeApp(movedToGrid, 'grid', 1, 'dock', 3)
expect(movedToDock.grid[1]).toBeNull()
expect(movedToDock.dock[1]).toBe('messages')
expect(movedToDock.dock[3]).toBe('messages')
})
it('removes shortcuts without closing gaps and restores the first gap', () => {
const layout: HomeLayout = moveHomeApp(defaults, 'grid', 0, 'grid', 10)
const removed = removeHomeApp(layout, 'phone')
expect(removed.grid[0]).toBeNull()
expect(removed.grid[10]).toBeNull()
expect(removed.dock[0]).toBeNull()
expect(removed.hidden).toContain('phone')
const restored = restoreHomeApp(removed, 'phone')
expect(restored.grid[0]).toBe('phone')
expect(restored.hidden).not.toContain('phone')
})
})
+259
View File
@@ -0,0 +1,259 @@
import type { LaunchablePhoneAppId } from '@/types/apps'
export const HOME_DOCK_CAPACITY = 4
export const HOME_GRID_PAGE_SIZE = 20
const MAX_HOME_GRID_PAGES = 5
export type HomeArea = 'dock' | 'grid'
export type HomeSlot = LaunchablePhoneAppId | null
export type HomeLayout = {
dock: HomeSlot[]
grid: HomeSlot[]
hidden: LaunchablePhoneAppId[]
version: 2
}
function readAppIds(
value: unknown,
availableIds: Set<LaunchablePhoneAppId>,
): 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 readSlots(
value: unknown,
availableIds: Set<LaunchablePhoneAppId>,
length: number,
): HomeSlot[] {
const slots = createSlots(length)
if (!Array.isArray(value)) return slots
for (let index = 0; index < Math.min(value.length, length); index += 1) {
const valueId = value[index]
if (
typeof valueId === 'string' &&
availableIds.has(valueId as LaunchablePhoneAppId)
) {
slots[index] = valueId as LaunchablePhoneAppId
}
}
return slots
}
function placeInFirstEmptySlot(
slots: HomeSlot[],
appId: LaunchablePhoneAppId,
): void {
const emptyIndex = slots.indexOf(null)
if (emptyIndex !== -1) {
slots[emptyIndex] = appId
return
}
slots.push(...createSlots(HOME_GRID_PAGE_SIZE))
slots[slots.length - HOME_GRID_PAGE_SIZE] = appId
}
function insertIntoSlot(
slots: HomeSlot[],
targetIndex: number,
appId: LaunchablePhoneAppId,
): HomeSlot {
if (slots[targetIndex] === null) {
slots[targetIndex] = appId
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] = appId
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] = appId
return null
}
const displacedApp = slots.at(-1) ?? null
for (let index = slots.length - 1; index > targetIndex; index -= 1) {
slots[index] = slots[index - 1]
}
slots[targetIndex] = appId
return displacedApp
}
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: 2 }
}
export function parseHomeLayout(
value: unknown,
defaults: HomeLayout,
installedIds: LaunchablePhoneAppId[],
): HomeLayout {
if (!value || typeof value !== 'object') return defaults
const source = value as Partial<Record<keyof HomeLayout, unknown>>
const availableIds = new Set(installedIds)
const hidden = readAppIds(source.hidden, availableIds)
const hiddenIds = new Set(hidden)
const persistedGridLength = Array.isArray(source.grid)
? 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 (source.version === 2) {
grid = readSlots(source.grid, availableIds, gridLength)
dock = readSlots(source.dock, availableIds, HOME_DOCK_CAPACITY)
} 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
}
}
grid = grid.map((id) => (id && !hiddenIds.has(id) ? id : null))
dock = dock.map((id) => (id && !hiddenIds.has(id) ? id : null))
const placedIds = new Set(
[...grid, ...dock, ...hidden].filter(
(id): id is LaunchablePhoneAppId => id !== null,
),
)
for (const id of defaults.grid) {
if (id && !placedIds.has(id)) {
placeInFirstEmptySlot(grid, id)
placedIds.add(id)
}
}
return { dock, grid, hidden, version: 2 }
}
export function removeHomeApp(
layout: HomeLayout,
appId: LaunchablePhoneAppId,
): HomeLayout {
return {
dock: layout.dock.map((id) => (id === appId ? null : id)),
grid: layout.grid.map((id) => (id === appId ? null : id)),
hidden: layout.hidden.includes(appId)
? [...layout.hidden]
: [...layout.hidden, appId],
version: 2,
}
}
export function restoreHomeApp(
layout: HomeLayout,
appId: LaunchablePhoneAppId,
): HomeLayout {
if (layout.grid.includes(appId) || layout.dock.includes(appId)) return layout
const grid = [...layout.grid]
placeInFirstEmptySlot(grid, appId)
return {
dock: [...layout.dock],
grid,
hidden: layout.hidden.filter((id) => id !== appId),
version: 2,
}
}
export function moveHomeApp(
layout: HomeLayout,
from: HomeArea,
sourceIndex: number,
to: HomeArea,
targetIndex: number,
): HomeLayout {
const next: HomeLayout = {
dock: [...layout.dock],
grid: [...layout.grid],
hidden: [...layout.hidden],
version: 2,
}
const source = next[from]
const target = next[to]
const appId = source[sourceIndex]
if (
!appId ||
sourceIndex < 0 ||
sourceIndex >= source.length ||
targetIndex < 0 ||
targetIndex >= target.length
) {
return layout
}
if (from === to) {
if (sourceIndex === targetIndex) return layout
source[sourceIndex] = null
insertIntoSlot(source, targetIndex, appId)
return next
}
source[sourceIndex] = insertIntoSlot(target, targetIndex, appId)
return next
}
+2
View File
@@ -50,6 +50,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
> = {
phone: { enabled: true, sounds: true },
messages: { enabled: true, sounds: true },
darkchat: { enabled: true, sounds: true },
'app-store': { enabled: true, sounds: true },
calculator: { enabled: true, sounds: true },
snake: { enabled: true, sounds: true },
@@ -65,6 +66,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
clock: { enabled: true, sounds: true },
calendar: { enabled: true, sounds: true },
weather: { enabled: true, sounds: true },
banking: { enabled: true, sounds: true },
mail: { enabled: true, sounds: true },
map: { enabled: true, sounds: true },
notes: { enabled: true, sounds: true },