Compare commits

..

13 Commits

Author SHA1 Message Date
DerEchteAlec 3314fa5355 Merge branch 'dev' into feat/refine-phone-setup 2026-08-20 18:25:45 +02:00
DerEchteAlec 637168c616 Merge branch 'dev' into feat/refine-phone-setup 2026-08-20 17:26:47 +02:00
DerEchteAlec 39fe171961 Merge branch 'dev' into feat/refine-phone-setup 2026-08-20 17:17:17 +02:00
smx.pusha c2fe8b5e23 FIX - correct messages GIF picker layout 2026-08-20 12:52:14 +02:00
smx.pusha a24cda989a ENH - apply liquid glass across app controls 2026-08-20 12:38:46 +02:00
smx.pusha b9f8930f00 ENH - refine phone liquid glass controls 2026-08-20 11:50:41 +02:00
smx.pusha 5efd806e43 ENH - refine notes header and compose controls 2026-08-20 11:02:44 +02:00
smx.pusha 65a0846eea FIX - align memo player content 2026-08-20 10:46:09 +02:00
smx.pusha 0a0053c3af ENH - align gallery filter navigation 2026-08-20 10:03:18 +02:00
smx.pusha 2f0faa8206 ENH - fade empty music widget content 2026-08-20 09:48:56 +02:00
smx.pusha 76b0378649 FIX - refine compact weather layout 2026-08-20 09:25:34 +02:00
smx.pusha 07b348a01a FIX - refine settings header and search icon 2026-08-20 08:45:58 +02:00
smx.pusha 9abf53b140 ENH - refine phone setup experience 2026-08-20 08:25:54 +02:00
93 changed files with 530 additions and 8361 deletions
-94
View File
@@ -26,100 +26,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## Inter 4.1
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
## Framework7 documentation placeholder media
The development-only Sky UI Kitchen Sink includes placeholder images mirrored
+9 -114
View File
@@ -11,7 +11,6 @@ import {
import { useRoute, useRouter } from 'vue-router'
import { SkyProvider } from '@/ui'
import AdminPanel from '@/components/AdminPanel.vue'
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'
@@ -57,7 +56,6 @@ import { useNotesStore } from '@/stores/notes'
import { useMemosStore } from '@/stores/memos'
import { useWeatherStore } from '@/stores/weather'
import { useEasyShareStore } from '@/stores/easyshare'
import { useRadioStore } from '@/stores/radio'
import {
useNotificationsStore,
type PhoneNotification,
@@ -72,15 +70,10 @@ import type {
CompanyUnreadCounts,
} from '@/types/companies'
import type { PhoneCall } from '@/types/phone'
import type { DynamicIslandActivity } from '@/types/dynamicIsland'
import type { EasyShareEvent } from '@/types/easyshare'
import type { CryptoMarketChangedData } from '@/types/crypto'
import type { CityWarnEventData } from '@/types/citywarn'
import { nuiCall } from '@/utils/nui'
import {
installPhoneAudioController,
setPhoneOutputVolume,
} from '@/utils/phoneAudio'
import { formatTimer } from '@/utils/clock'
import { parsePhonePreferences } from '@/utils/preferences'
import { getHairlinePixelStyle } from '@/utils/rendering'
@@ -89,7 +82,6 @@ import { isTrustedRootMessageSource } from '@/utils/windowMessages'
import SpringboardView from '@/views/SpringboardView.vue'
type AppMessage = {
openHome?: boolean
type?: string
data?:
| CalendarReminderData
@@ -115,13 +107,8 @@ type AppMessage = {
| CustomAppCatalogEventData
| CustomAppEventData
| NavigationEventData
| AdminPanelOpenPayload
}
type AdminPanelOpenPayload = Required<
Pick<PhoneOpenPayload, 'fallbackLocales' | 'lang' | 'locales'>
>
type CustomAppCatalogEventData = {
apps?: unknown
}
@@ -326,7 +313,6 @@ const notes = useNotesStore()
const memos = useMemosStore()
const weather = useWeatherStore()
const easyShare = useEasyShareStore()
const radio = useRadioStore()
const notifications = useNotificationsStore()
const route = useRoute()
const router = useRouter()
@@ -349,21 +335,13 @@ const DARK_STATUS_BAR_APP_IDS = new Set([
'minesweeper',
'number-merge',
])
const isDynamicIslandGalleryRoute = computed(
() => isDevelopment && route.name === 'development-dynamic-islands',
)
const isDevelopmentRoute = computed(
() =>
isDevelopment &&
(route.name === 'development-sky-ui' || isDynamicIslandGalleryRoute.value),
() => isDevelopment && route.name === 'development-sky-ui',
)
const appTransitionName = computed(() =>
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
)
const isLocked = ref(false)
const adminPanelOpen = ref(
isDevelopment && developmentParameters.has('adminPanel'),
)
const springboardEditing = ref(false)
const isUnlocking = ref(false)
const passcodeBusy = ref(false)
@@ -378,12 +356,9 @@ const setupPreviewDismissed = ref(false)
const setupDevelopmentSkipped = ref(false)
const setupAppearanceSelected = ref(false)
const pendingUnlockRoute = ref<string | null>(null)
const openHomeRequested = ref(false)
const unlockedServicesLoaded = ref(false)
const controlCenterOpened = ref(false)
const activitySuspended = ref(false)
const dynamicIslandExpanded = ref(false)
const dynamicIslandActivity = ref<DynamicIslandActivity | null>(null)
const simPicker = ref<SimPickerPayload | null>(null)
const setupRequired = computed(
() =>
@@ -457,12 +432,6 @@ const phoneResolutionStyle = computed<CSSProperties>(() => ({
}))
const phoneStageStyle = computed<CSSProperties>(() => ({
...phoneResolutionStyle.value,
'--phone-live-activity-peek-height':
dynamicIslandActivity.value === 'music'
? '190px'
: dynamicIslandActivity.value === 'recording'
? '132px'
: '112px',
visibility: activitySuspended.value ? 'hidden' : 'visible',
}))
const phoneDisplayStyle = computed<CSSProperties>(() => ({
@@ -480,7 +449,6 @@ let unlockTimer: number | undefined
let passcodeLockTimer: number | undefined
let hardwareVolumeHudTimer: number | undefined
let unlockedServicesIdle: number | undefined
let removePhoneAudioController: (() => void) | undefined
let phoneClosePending = false
let simPickerClosePending = false
@@ -636,8 +604,6 @@ function loadUnlockedPhoneData(): void {
}
function completePhoneSetup(): void {
const requestedRoute = pendingUnlockRoute.value
pendingUnlockRoute.value = null
setupPreviewDismissed.value = true
setupAppearanceSelected.value = false
isLocked.value = false
@@ -645,7 +611,7 @@ function completePhoneSetup(): void {
passcodeVisible.value = false
passcodeRequired.value = false
controlCenterOpened.value = false
void router.replace(requestedRoute ?? '/')
void router.replace('/')
loadUnlockedPhoneData()
}
@@ -729,15 +695,7 @@ function openDevelopmentPayphonePreview(): void {
function onMessage(event: MessageEvent<AppMessage>): void {
if (!isTrustedRootMessageSource(event.source, window)) return
if (event.data?.type === 'admin:open') {
const data = event.data.data as AdminPanelOpenPayload | undefined
if (data?.lang && data.locales && data.fallbackLocales) {
phone.setLocale(data.lang, data.locales, data.fallbackLocales)
}
adminPanelOpen.value = true
} else if (event.data?.type === 'admin:close') {
adminPanelOpen.value = false
} else if (event.data?.type === 'custom-apps:catalog') {
if (event.data?.type === 'custom-apps:catalog') {
appCatalog.replaceCatalog(event.data.data)
const catalogPayload = event.data.data as
| { apps?: unknown; debug?: unknown }
@@ -782,12 +740,7 @@ function onMessage(event: MessageEvent<AppMessage>): void {
isPhoneAppId(data.appId) &&
appStore.isInstalled(data.appId)
) {
const requestedRoute = `/apps/${data.appId}`
if (setupRequired.value || isLocked.value) {
pendingUnlockRoute.value = requestedRoute
} else {
void router.push(requestedRoute)
}
void router.push(`/apps/${data.appId}`)
} else {
console.error('[Navigation] Ignored an unavailable app target.')
}
@@ -805,7 +758,6 @@ function onMessage(event: MessageEvent<AppMessage>): void {
})
}
} else if (event.data?.type === 'app:open') {
openHomeRequested.value = event.data.openHome === true
hydratePhone(event.data.data as PhoneOpenPayload)
void syncNavigationState().then(() => nuiCall('ui:opened'))
} else if (event.data?.type === 'device:updated') {
@@ -1521,7 +1473,6 @@ function onFocusOut(event: FocusEvent): void {
}
onMounted(() => {
removePhoneAudioController = installPhoneAudioController()
document.addEventListener('focusin', onFocusIn)
document.addEventListener('focusout', onFocusOut)
window.addEventListener('message', onMessage)
@@ -1636,23 +1587,6 @@ watch(
},
)
watch(
() => Boolean(dynamicIslandActivity.value || calls.activeCall),
(active) => {
void nuiCall('ui:live-activity', { active })
},
{ immediate: true },
)
watch(
hardwareAlertVolume,
(volume) => {
setPhoneOutputVolume(volume / 100)
if (radio.data.connected) void radio.setVolume(volume)
},
{ immediate: true },
)
watch(
() => phone.isOpen,
(isOpen) => {
@@ -1685,8 +1619,7 @@ watch(
}
isLocked.value = setupRequired.value
? false
: developmentLockScreenPreview ||
(!isDevelopment && phone.security.enabled)
: !isDevelopment || developmentLockScreenPreview
passcodeRequired.value = isLocked.value && phone.security.enabled
unlockedServicesLoaded.value = false
controlCenterOpened.value = false
@@ -1704,18 +1637,8 @@ watch(
startPasscodeLock(passcodeRetrySeconds.value)
}
phone.setLaunchOrigin(null)
if (setupRequired.value) {
void router.replace('/')
} else if (openHomeRequested.value) {
openHomeRequested.value = false
if (isLocked.value) pendingUnlockRoute.value = '/'
else {
void router.replace('/')
loadUnlockedPhoneData()
}
} else if (!isLocked.value) {
loadUnlockedPhoneData()
}
if (isLocked.value || setupRequired.value) void router.replace('/')
else loadUnlockedPhoneData()
},
)
@@ -1727,7 +1650,6 @@ watch(
)
onBeforeUnmount(() => {
removePhoneAudioController?.()
updateTextInputFocus(false)
cancelUnlockedPhoneDataLoad()
weather.stop()
@@ -1750,15 +1672,6 @@ onBeforeUnmount(() => {
</script>
<template>
<SkyProvider
v-if="adminPanelOpen"
dark
:safe-areas="false"
accent="#74d66f"
accent-soft="rgba(116, 214, 111, 0.14)"
>
<AdminPanel @close="adminPanelOpen = false" />
</SkyProvider>
<PhoneMediaCapture />
<PhoneMemoRecorder />
<RadioHud />
@@ -1775,15 +1688,12 @@ onBeforeUnmount(() => {
phone.isOpen ||
notifications.current ||
calls.activeCall ||
dynamicIslandActivity ||
notifications.devicePreviews.length
"
class="phone-stage"
:class="{
'phone-stage--browser-preview': isBrowserPreview,
'phone-stage--landscape': phone.cameraLandscape,
'phone-stage--live-activity':
!phone.isOpen && Boolean(dynamicIslandActivity || calls.activeCall),
'phone-stage--peek': notifications.isPeeking,
}"
:style="phoneStageStyle"
@@ -1803,25 +1713,14 @@ onBeforeUnmount(() => {
@open="openNotificationPreview"
/>
<div
v-if="
phone.isOpen ||
notifications.current ||
calls.activeCall ||
dynamicIslandActivity
"
v-if="phone.isOpen || notifications.current || calls.activeCall"
class="phone-resolution-wrapper phone-resolution-wrapper--primary"
>
<div
id="phone-home-drag-portal"
class="phone-home-drag-portal"
aria-hidden="true"
></div>
<div class="phone-resolution-canvas phone-resolution-canvas--primary">
<section
class="phone-device"
:class="{
'phone-app--light': !displayedDarkMode,
'phone-device--island-expanded': dynamicIslandExpanded,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
:aria-label="phone.t('Common.phone')"
@@ -1933,6 +1832,7 @@ onBeforeUnmount(() => {
@control-center="toggleControlCenter"
@lock="lockPhone"
/>
<PhoneDynamicIsland v-if="!setupRequired" />
<SpringboardView
v-if="!isDevelopmentRoute && !setupRequired"
@edit-mode-change="springboardEditing = $event"
@@ -2018,11 +1918,6 @@ onBeforeUnmount(() => {
aria-hidden="true"
draggable="false"
/>
<PhoneDynamicIsland
v-if="!setupRequired && !isDynamicIslandGalleryRoute"
@expanded-change="dynamicIslandExpanded = $event"
@live-activity-change="dynamicIslandActivity = $event"
/>
</section>
</div>
</div>
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8').replace(/\r\n/g, '\n')
const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8')
const mainCss = readFileSync(
new URL('./assets/main.css', import.meta.url),
'utf8',
@@ -22,32 +22,15 @@ describe('browser development preview contract', () => {
it('starts unlocked while preserving an explicit lock screen preview', () => {
expect(source).toContain("developmentParameters.has('lockScreenPreview')")
expect(source).toContain(
'developmentLockScreenPreview ||\n (!isDevelopment && phone.security.enabled)',
)
expect(source).toContain(': !isDevelopment || developmentLockScreenPreview')
expect(source).toContain("developmentParameters.has('setupPreview')")
})
it('restores the active route after the lock screen', () => {
expect(source).toMatch(
/if \(setupRequired\.value\) \{[\s\S]*?router\.replace\('\/'\)/,
)
expect(source).toMatch(
/else if \(!isLocked\.value\) \{\s*loadUnlockedPhoneData\(\)/,
)
expect(source).not.toContain(
it('loads authenticated app data without replacing direct app routes', () => {
expect(source).toContain(
"if (isLocked.value || setupRequired.value) void router.replace('/')",
)
})
it('opens Space-triggered live activities on Home or the enabled lock screen', () => {
expect(source).toContain(
'openHomeRequested.value = event.data.openHome === true',
)
expect(source).toContain(
"if (isLocked.value) pendingUnlockRoute.value = '/'",
)
expect(source).toContain("void router.replace('/')")
expect(source).toContain('else loadUnlockedPhoneData()')
})
it('requires the passcode again after a full device lock', () => {
@@ -92,10 +75,6 @@ describe('browser development preview contract', () => {
it('uses layout zoom so the fixed-resolution phone stays sharply rasterized', () => {
expect(source).toContain('phone-resolution-canvas--primary')
expect(source).toMatch(
/phone-resolution-wrapper--primary[\s\S]*?id="phone-home-drag-portal"[\s\S]*?phone-resolution-canvas--primary/,
)
expect(source.match(/id="phone-home-drag-portal"/g)).toHaveLength(1)
expect(source).toContain("'--phone-rendered-height'")
expect(source).toContain("'--phone-rendered-width'")
expect(mainCss).toMatch(
@@ -121,9 +100,7 @@ describe('browser development preview contract', () => {
expect(source).toContain("developmentParameters.has('browserPreview')")
expect(source).toContain('import.meta.env.DEV ||')
expect(source).toContain("'phone-stage--browser-preview': isBrowserPreview")
expect(source).toContain(
'return (availableScale * 0.94) / PHONE_BASE_SCALE',
)
expect(source).toContain('return (availableScale * 0.94) / PHONE_BASE_SCALE')
expect(mainCss).toMatch(
/\.phone-stage--browser-preview\s*\{[^}]*place-items:\s*center;[^}]*padding:\s*0;/s,
)
Binary file not shown.
Binary file not shown.
+87 -70
View File
@@ -211,11 +211,9 @@
}
}
:root {
font-family: var(--sky-font-family);
font-feature-settings:
'liga' 1,
'calt' 1;
font-optical-sizing: auto;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue',
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
@@ -236,9 +234,7 @@ body,
user-select: none;
}
button,
input,
textarea,
select {
input {
font: inherit;
}
button {
@@ -263,20 +259,6 @@ button {
transform: translateY(calc(100% - 190px));
transform-origin: right bottom;
}
.phone-stage--live-activity .phone-resolution-wrapper--primary .phone-device {
pointer-events: none;
transform: translateY(
calc(100% - var(--phone-live-activity-peek-height, 145px))
);
transform-origin: right bottom;
}
.phone-stage--live-activity
.phone-device
> :not(.phone-screen):not(.phone-device__frame):not(.phone-dynamic-island),
.phone-stage--live-activity .phone-screen > * {
visibility: hidden;
}
.phone-lift-enter-active {
transition: opacity 0.52s linear;
}
@@ -299,18 +281,6 @@ button {
height: var(--phone-rendered-height, 844px);
}
.phone-home-drag-portal {
position: absolute;
z-index: 70;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.phone-home-drag-portal * {
pointer-events: none !important;
}
.phone-resolution-canvas {
position: absolute;
top: 0;
@@ -414,7 +384,7 @@ button {
overflow: hidden;
border: 1px solid rgb(255 255 255 / 16%);
border-radius: 15px;
color: #a9abb2;
color: #0a84ff;
background: rgb(47 47 51 / 98%);
box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
pointer-events: none;
@@ -702,9 +672,6 @@ button {
transition: transform 280ms var(--sky-ease-out, ease-out);
will-change: transform;
}
.phone-device--island-expanded .phone-notification {
top: 130px !important;
}
.phone-notification__icon {
width: 38px;
height: 38px;
@@ -828,6 +795,78 @@ button {
cursor: pointer;
pointer-events: auto;
}
.phone-dynamic-island {
position: absolute;
z-index: 98;
top: 12px;
left: 50%;
display: flex;
align-items: center;
gap: 10px;
width: 300px;
min-height: 72px;
padding: 10px 12px 10px 16px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 28px;
color: #fff;
background: #050505;
box-shadow: 0 8px 24px rgb(0 0 0 / 45%);
transform: translateX(-50%);
}
.phone-dynamic-island__caller {
min-width: 0;
flex: 1;
}
.phone-dynamic-island__caller span,
.phone-dynamic-island__caller strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.phone-dynamic-island__caller span {
color: rgb(255 255 255 / 58%);
font-size: 11px;
line-height: 14px;
}
.phone-dynamic-island__caller strong {
margin-top: 2px;
font-size: 15px;
line-height: 18px;
}
.phone-dynamic-island__actions {
display: flex;
gap: 8px;
}
.phone-dynamic-island__actions .sky-button--icon-only {
width: 44px;
min-width: 44px;
height: 44px;
}
.phone-dynamic-island__actions svg {
width: 19px;
height: 19px;
}
.phone-dynamic-island__answer {
--sky-app-accent: #34c759;
}
.phone-dynamic-island-enter-active,
.phone-dynamic-island-leave-active {
transition:
opacity 180ms ease,
transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
.phone-dynamic-island-enter-from,
.phone-dynamic-island-leave-to {
opacity: 0;
transform: translateX(-50%) scale(0.86);
}
@media (prefers-reduced-motion: reduce) {
.phone-dynamic-island-enter-active,
.phone-dynamic-island-leave-active {
transition: none;
}
}
.phone-home-indicator {
position: absolute;
z-index: 90;
@@ -1302,7 +1341,8 @@ button {
overflow: hidden;
background: #000;
color: #f5f5f7;
font-family: var(--sky-font-family);
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
}
.darkchat-page button,
.darkchat-page input,
@@ -2637,30 +2677,8 @@ button {
.springboard--widget-dragging .springboard-widget-page-scroll {
overflow: visible;
}
.home-drag-layer {
position: absolute;
overflow: hidden;
pointer-events: none;
}
.home-drag-layer * {
pointer-events: none !important;
}
.home-drag-position {
position: absolute;
top: 0;
left: 0;
will-change: transform;
}
.home-drag-ghost {
position: absolute;
top: 0;
left: 0;
margin: 0;
opacity: 1;
transition: none !important;
animation: none !important;
transform-origin: 0 0;
will-change: transform;
.springboard--home-dragging .springboard-page--apps {
overflow: visible;
}
.app-grid {
display: grid;
@@ -2695,9 +2713,6 @@ button {
transition: transform var(--springboard-page-duration)
var(--springboard-page-easing);
}
.app-icon-item--drag-source {
opacity: 0;
}
.app-icon-remove {
position: absolute;
z-index: 4;
@@ -2823,6 +2838,7 @@ button {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.app-icon--calculator {
background: linear-gradient(145deg, #76767b, #1b1b1d);
@@ -7581,17 +7597,18 @@ button {
min-height: var(--springboard-grid-row);
}
.app-icon-button {
font-family: var(--sky-font-family);
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.app-icon-label {
min-height: var(--sky-home-label-height);
font-size: var(--sky-home-label-font-size);
min-height: 15px;
font-size: 11.5px;
font-weight: 500;
letter-spacing: -0.15px;
line-height: var(--sky-home-label-height);
line-height: 15px;
text-align: center;
}
.springboard-edit-add {
@@ -14,10 +14,6 @@ const callsServer = readFileSync(
new URL('../../sky_phone/source/server/calls.lua', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const phoneServer = readFileSync(
new URL('../../sky_phone/source/server/phone.lua', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const companiesStore = readFileSync(
new URL('./stores/companies.ts', import.meta.url),
'utf8',
@@ -89,35 +85,3 @@ describe('Companies outbound service-line call contract', () => {
expect(startCompanyCall).not.toContain('data.callerNumber')
})
})
describe('Companies background call availability contract', () => {
it('keeps call availability enabled after the phone UI closes', () => {
const closeDevice = sourceBlock(
phoneServer,
`Bridge.Callbacks.Register(${quote}sky_phone:device:close${quote}`,
`Bridge.Callbacks.Register(${quote}sky_phone:device:notification-open${quote}`,
)
expect(closeDevice).toContain('sessions[source] = nil')
expect(closeDevice).not.toContain(
'SkyPhoneCompanies.ClearCallAvailability(source)',
)
})
it('routes background calls only to an owned phone with the same registered SIM', () => {
const getCallTargets = sourceBlock(
companiesServer,
'function SkyPhoneCompanies.GetCallTargets(',
'\n\nlocal function profile_row(',
)
expect(getCallTargets).toContain('SkyPhone.LoadDevice(readiness.imei)')
expect(getCallTargets).toContain(
'SkyPhone.FindDeviceSlots(source, readiness.imei)',
)
expect(getCallTargets).toContain('device.sim_id == readiness.sim_id')
expect(getCallTargets).toContain('device.sim_type == "registered"')
expect(getCallTargets).toContain('device.registered_at ~= nil')
expect(getCallTargets).not.toContain('current_device(source, true)')
})
})
@@ -1,206 +0,0 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./AdminPanel.vue', import.meta.url),
'utf8',
)
const app = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
const apps = readFileSync(new URL('../config/apps.ts', import.meta.url), 'utf8')
const store = readFileSync(
new URL('../stores/admin.ts', import.meta.url),
'utf8',
)
const server = readFileSync(
new URL('../../../sky_phone/source/server/admin.lua', import.meta.url),
'utf8',
)
const phoneServer = readFileSync(
new URL('../../../sky_phone/source/server/phone.lua', import.meta.url),
'utf8',
)
const persistenceServer = readFileSync(
new URL(
'../../../sky_phone/source/server/phone_persistence.lua',
import.meta.url,
),
'utf8',
)
const simServer = readFileSync(
new URL('../../../sky_phone/source/server/sim.lua', import.meta.url),
'utf8',
)
const phoneClient = readFileSync(
new URL('../../../sky_phone/source/client/main.lua', import.meta.url),
'utf8',
)
const focusClient = readFileSync(
new URL('../../../sky_phone/source/client/focus.lua', import.meta.url),
'utf8',
)
const bridge = readFileSync(
new URL(
'../../../sky_phone/source/client/nui_server_bridge.lua',
import.meta.url,
),
'utf8',
)
const config = readFileSync(
new URL('../../../sky_phone/config/config.lua', import.meta.url),
'utf8',
)
const schema = readFileSync(
new URL('../../../sky_phone/sql/install.sql', import.meta.url),
'utf8',
)
describe('standalone admin panel contracts', () => {
it('renders as a dedicated full-screen editor outside the phone shell', () => {
expect(apps).not.toContain("id: 'admin'")
expect(app).toContain("event.data?.type === 'admin:open'")
expect(app).toContain('v-if="adminPanelOpen"')
expect(app).toContain('<AdminPanel')
expect(source).toContain("import { SkyButton } from '@/ui'")
expect(source).toContain('class="admin-panel-overlay"')
expect(source).toContain('class="admin-panel-rail"')
expect(source).toContain('class="admin-panel-directory"')
expect(source).toContain('class="admin-panel-editor"')
expect(source).toContain('pointer-events: auto')
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
})
it('uses a compact transparent shell with dedicated admin workspaces', () => {
expect(source).toContain('background: transparent')
expect(source).toContain('width: min(76vw, 1220px)')
expect(source).toContain('height: min(74vh, 700px)')
expect(source).toContain('--admin-row-hover: linear-gradient')
expect(source).toContain('--admin-row-active: linear-gradient')
expect(source).toContain('background: var(--admin-nav-active)')
expect(source).not.toContain('admin-panel-brand__mark')
expect(source).not.toContain('admin-panel-profile-heading__status')
expect(source).not.toContain('backdrop-filter: blur(2px)')
for (const tab of [
'overview',
'players',
'devices',
'apps',
'accounts',
'messages',
'calls',
'moderation',
'audit',
]) {
expect(source).toContain(`selectTab('${tab}')`)
expect(source).toContain(`t('tabs.${tab}')`)
}
})
it('removes manual reload and applies a persistent global accent choice', () => {
expect(source).not.toContain('<RefreshCw')
expect(source).not.toContain("kind: 'refresh'")
expect(source).toContain("'sky-phone-admin-accent'")
expect(source).toContain(':style="{ \'--admin-accent\': accentColor }"')
expect(source).toContain('color-mix(in srgb, var(--admin-green)')
})
it('stages app changes locally and saves them only from the toolbar action', () => {
expect(source).toContain('const drafts = ref<')
expect(source).toContain('@click="saveChanges"')
expect(source).toContain("t('editor.noAutoSave')")
expect(store).toContain("'admin:save-apps'")
expect(store).not.toContain("'admin:set-app'")
expect(server).toContain(
'Bridge.Callbacks.Register("sky_phone:admin:save-apps"',
)
})
it('connects every operation through the standard NUI callback bridge', () => {
expect(bridge).toContain('admin = [[')
for (const endpoint of [
'admin:bootstrap',
'admin:player',
'admin:save-apps',
'admin:reveal-password',
'admin:activity',
'admin:reset-passcode',
'admin:change-number',
'admin:factory-reset',
]) {
expect(store).toContain(endpoint)
}
})
it('protects activity views and device moderation with ownership and audit checks', () => {
for (const endpoint of [
'activity',
'reset-passcode',
'change-number',
'factory-reset',
]) {
expect(server).toContain(
`Bridge.Callbacks.Register("sky_phone:admin:${endpoint}"`,
)
}
expect(server).toContain('data.kind ~= "messages"')
expect(server).toContain('data.kind ~= "calls"')
expect(server).toContain('"view_messages"')
expect(server).toContain('"view_calls"')
expect(server).toContain('"reset_passcode"')
expect(server).toContain('"change_number"')
expect(server).toContain('"factory_reset"')
expect(simServer).toContain('function SkyPhoneSim.ChangeNumber(')
expect(simServer).toContain('UPDATE IGNORE `sky_phone_sims`')
expect(persistenceServer).toContain(
'function SkyPhonePersistence.FactoryReset(imei)',
)
})
it('authorizes every server request without requiring a phone session', () => {
expect(server).not.toContain('SkyPhone.RequireSession(source)')
expect(server).toContain(
'Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)',
)
expect(server).toContain('Config.AdminPanel.ReadRequestsPerMinute')
expect(server).toContain('Config.AdminPanel.ActionRequestsPerMinute')
expect(server).toContain('Config.AdminPanel.CredentialRevealsPerMinute')
expect(config).toContain('Config.AdminPanel = {')
})
it('opens directly from the configurable command with dedicated focus', () => {
expect(config).toContain('Command = "phoneadmin"')
expect(server).toContain(
'RegisterCommand(Config.AdminPanel.Command, function(command_source)',
)
expect(server).toContain(
'TriggerClientEvent("sky_phone:admin:launch", player_source)',
)
expect(phoneServer).not.toContain(
'RegisterCommand(Config.AdminPanel.Command',
)
expect(phoneClient).toContain('RegisterNetEvent("sky_phone:admin:launch"')
expect(phoneClient).toContain('SkyPhoneFocus.SetAdminPanel(true)')
expect(focusClient).toContain('function SkyPhoneFocus.SetAdminPanel(open)')
})
it('validates ownership, app policy, and revision before a batch mutation', () => {
expect(server).toContain('find_owned_device(target_source, data.imei)')
expect(server).toContain('app_metadata(change.appId)')
expect(server).toContain('error = "device_not_owned"')
expect(server).toContain('error = "app_protected"')
expect(server).toContain('AND `revision` = ?')
expect(server).toContain('SkyPhone.RefreshDevice(data.imei)')
})
it('gates plaintext account reveals behind confirmation and audit logging', () => {
expect(source).toContain('revealDialogImei')
expect(source).toContain("t('credentials.revealTitle')")
expect(server).toContain('"reveal_account_password"')
expect(server).toContain('write_audit(')
expect(server).not.toContain('passcode_hash` AS')
expect(schema).toContain(
'CREATE TABLE IF NOT EXISTS `sky_phone_admin_audit`',
)
})
})
File diff suppressed because it is too large Load Diff
+4 -10
View File
@@ -30,13 +30,11 @@ const props = withDefaults(
app: PhoneAppDefinition
compact?: boolean
editMode?: boolean
externalDragVisual?: boolean
showLabel?: boolean
}>(),
{
compact: false,
editMode: false,
externalDragVisual: false,
showLabel: true,
},
)
@@ -65,14 +63,14 @@ let dragStartPage = 0
let dragPageWidth = 0
let dragPageMetrics: SpringboardDragMetrics | null = null
const dragStyle = computed(() =>
isDragging.value && !props.externalDragVisual
isDragging.value
? {
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
}
: undefined,
)
const dragPointerStyle = computed(() =>
isDragging.value && !props.externalDragVisual
isDragging.value
? {
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
}
@@ -302,7 +300,6 @@ onBeforeUnmount(() => {
class="app-icon-item"
:class="{
'app-icon-item--compact': compact,
'app-icon-item--drag-source': isDragging && externalDragVisual,
'app-icon-item--dragging': isDragging,
'app-icon-item--editing': editMode,
}"
@@ -328,10 +325,7 @@ onBeforeUnmount(() => {
class="app-icon"
:class="[
app.iconClass,
{
'app-icon--image':
!iconFailed && Boolean(app.iconImage) && app.id !== 'calendar',
},
{ 'app-icon--image': !iconFailed && app.id !== 'calendar' },
]"
:style="iconStyle"
>
@@ -340,7 +334,7 @@ onBeforeUnmount(() => {
<b>{{ calendarDay }}</b>
</span>
<img
v-else-if="!iconFailed && app.iconImage"
v-else-if="!iconFailed"
:src="app.iconImage"
alt=""
draggable="false"
+2 -5
View File
@@ -22,13 +22,11 @@ const props = withDefaults(
apps: PhoneAppDefinition[]
defaultName: string
editMode?: boolean
externalDragVisual?: boolean
folder: HomeFolder
showLabel?: boolean
}>(),
{
editMode: false,
externalDragVisual: false,
showLabel: true,
},
)
@@ -57,14 +55,14 @@ let stopPointerSession: (() => void) | null = null
const folderName = computed(() => props.folder.name || props.defaultName)
const dragStyle = computed(() =>
isDragging.value && !props.externalDragVisual
isDragging.value
? {
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
}
: undefined,
)
const dragPointerStyle = computed(() =>
isDragging.value && !props.externalDragVisual
isDragging.value
? {
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
}
@@ -234,7 +232,6 @@ onBeforeUnmount(() => {
<div
class="home-folder-item app-icon-item"
:class="{
'app-icon-item--drag-source': isDragging && externalDragVisual,
'app-icon-item--dragging': isDragging,
'app-icon-item--editing': editMode,
'home-folder-item--dragging': isDragging,
@@ -331,7 +331,8 @@ function finishPageSwipe(event: PointerEvent): void {
z-index: 70;
inset: 0;
color: #fff;
font-family: var(--sky-font-family);
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
}
.home-folder-backdrop {
+2 -5
View File
@@ -3,7 +3,6 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import payphoneFrame from '@/assets/img/payphone/american-payphone-frame.png'
import { nuiCall } from '@/utils/nui'
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type PayphoneState =
@@ -71,9 +70,7 @@ const buttonSounds: HTMLAudioElement[] = []
function prepareButtonSounds(): void {
if (buttonSounds.length) return
for (let index = 0; index < 4; index += 1) {
const sound = registerPhoneMediaElement(
new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`),
)
const sound = new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`)
sound.preload = 'auto'
sound.volume = 0.55
buttonSounds.push(sound)
@@ -402,7 +399,7 @@ onBeforeUnmount(() => {
rgb(30 38 42 / 35%),
rgb(0 0 0 / 84%) 72%
);
font-family: var(--sky-font-family);
font-family: 'Segoe UI', Arial, sans-serif;
pointer-events: auto;
user-select: none;
}
@@ -5,15 +5,8 @@ import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./PhoneDynamicIsland.vue', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const appSource = readFileSync(
new URL('../App.vue', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const mainCss = readFileSync(
new URL('../assets/main.css', import.meta.url),
'utf8',
)
const appSource = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
const recorderSource = readFileSync(
new URL('./PhoneMemoRecorder.vue', import.meta.url),
'utf8',
@@ -26,23 +19,16 @@ const clockSource = readFileSync(
new URL('../views/apps/ClockApp.vue', import.meta.url),
'utf8',
)
const serverMemosSource = readFileSync(
new URL('../../../sky_phone/source/server/memos.lua', import.meta.url),
'utf8',
)
describe('Phone Dynamic Island contract', () => {
it('renders once at phone shell level instead of forcing calls into Phone', () => {
expect(appSource).toContain(
"import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'",
)
expect(appSource).toContain('<PhoneDynamicIsland v-if="!setupRequired" />')
expect(appSource).toContain(
"'phone-device--island-expanded': dynamicIslandExpanded",
'phone.isOpen || notifications.current || calls.activeCall',
)
expect(appSource).toMatch(
/class="phone-device__frame"[\s\S]*?<PhoneDynamicIsland[\s\S]*?@expanded-change="dynamicIslandExpanded = \$event"[\s\S]*?@live-activity-change="dynamicIslandActivity = \$event"/,
)
expect(appSource).toContain('dynamicIslandActivity ||')
expect(appSource).not.toContain(
"window.setTimeout(() => void router.push('/apps/phone'), 0)",
)
@@ -59,44 +45,7 @@ describe('Phone Dynamic Island contract', () => {
expect(source).toContain('call.answeredAt ?? call.startedAt')
})
it('hides an activity in its owning foreground app but restores it after closing', () => {
expect(source).toContain(
'if (!currentActivity || !phone.isOpen) return currentActivity',
)
expect(source).toContain("activeAppId.value === 'phone'")
expect(source).toContain(
"currentActivity === 'recording' && activeAppId.value === 'memos'",
)
expect(source).toContain("activeAppId.value === 'clock'")
expect(source).toContain(
"currentActivity === 'music' && activeAppId.value === 'music'",
)
})
it('shows only a compact frame and island for background live activities', () => {
expect(source).toContain("'live-activity-change': [activity:")
expect(source).toContain("emit('live-activity-change', nextActivity)")
expect(appSource).toContain(
"'phone-stage--live-activity':\n !phone.isOpen && Boolean(dynamicIslandActivity || calls.activeCall)",
)
expect(mainCss).toMatch(
/\.phone-stage--live-activity[\s\S]*?pointer-events:\s*none;[\s\S]*?--phone-live-activity-peek-height/,
)
expect(mainCss).toMatch(
/\.phone-stage--live-activity[\s\S]*?> :not\(\.phone-screen\):not\(\.phone-device__frame\):not\(\.phone-dynamic-island\)[\s\S]*?\.phone-screen > \*[\s\S]*?visibility:\s*hidden;/,
)
expect(appSource).toContain("? '190px'")
expect(appSource).toContain("? '132px'")
expect(appSource).toContain(": '112px'")
expect(source).toContain(
"!phone.isOpen ||\n activity.value === 'incoming-call' ||",
)
})
it('connects music, recorder, timer, and stopwatch controls to their stores', () => {
expect(source).toContain(
"if (music.isPlaying && music.currentTrack) return 'music'",
)
expect(source).toContain('@click.stop="music.previous()"')
expect(source).toContain('@click.stop="music.toggle()"')
expect(source).toContain('@click.stop="music.next()"')
@@ -104,31 +53,9 @@ describe('Phone Dynamic Island contract', () => {
expect(source).toContain('clock.pauseTimer(Date.now())')
expect(source).toContain('clock.pauseStopwatch(Date.now())')
expect(source).toContain('clock.addLap(Date.now())')
expect(source).not.toContain('phone-dynamic-island__lap')
expect(source).toContain('phone-dynamic-island__stopwatch-meta')
expect(source).toContain('{{ stopwatchLapLabel }}')
expect(source).toContain('{{ stopwatchLapValue }}')
expect(source).toContain('{{ stopwatchTotalDisplay }}')
})
it('matches the reference music player and timer control layouts', () => {
expect(source).toContain('phone-dynamic-island__music-equalizer')
expect(source).toContain('phone-dynamic-island__progress-track')
expect(source).toContain('{{ musicElapsedLabel }}')
expect(source).toContain('{{ musicRemainingLabel }}')
expect(source).not.toContain('Airplay')
expect(source).toContain('<X aria-hidden="true" />')
expect(source).toMatch(
/\.phone-dynamic-island--timer\.phone-dynamic-island__copy|\.phone-dynamic-island--timer \.phone-dynamic-island__copy/,
)
expect(source).toContain(
'.phone-dynamic-island--music .phone-dynamic-island__actions--media',
)
expect(source).toContain('justify-content: center')
expect(source).toContain('gap: 34px')
})
it('keeps recorder state available across app changes and phone closes', () => {
it('keeps recorder state available across app changes', () => {
expect(recorderSource).toContain(
"message.type === 'memo:recordStateRequest'",
)
@@ -138,15 +65,6 @@ describe('Phone Dynamic Island contract', () => {
expect(memosSource).not.toContain(
"if (recordingActive.value) postRecorderCommand('memo:recordCancel')",
)
expect(recorderSource).toContain('() => phone.device?.imei ?? null')
expect(recorderSource).not.toContain(
'if (!isOpen || deviceSessionChanged) cancelRecording()',
)
expect(recorderSource).toContain('deviceImei: finalDeviceImei')
expect(serverMemosSource).toContain('device_owner(src, data.deviceImei)')
expect(serverMemosSource).toContain(
'SkyPhone.FindDeviceSlots(source, imei)[1]',
)
})
it('opens both clock live activities on the correct clock tab', () => {
@@ -157,68 +75,13 @@ describe('Phone Dynamic Island contract', () => {
)
})
it('opens activities by tapping the island without a separate expand icon', () => {
expect(source).toContain('@click.stop="toggleExpanded"')
expect(source).toContain('@click.stop="openActivity"')
expect(source).not.toContain('Maximize2')
expect(source).not.toContain('phone-dynamic-island__open-icon')
})
it('collapses expanded activities on taps, swipes, and scrolling outside', () => {
expect(source).toContain('ref="islandElement"')
expect(source).toContain(
"document.addEventListener('pointerdown', onOutsidePointerDown, true)",
)
expect(source).toContain(
"document.addEventListener('scroll', collapseExpanded, true)",
)
expect(source).toContain('islandElement.value?.contains(event.target)')
expect(source).toContain('expanded.value = false')
expect(source).toContain(
"document.removeEventListener('pointerdown', onOutsidePointerDown, true)",
)
expect(source).toContain(
"document.removeEventListener('scroll', collapseExpanded, true)",
)
})
it('animates state changes and moves popup notifications below expanded UI', () => {
expect(source).toContain('<Transition name="phone-dynamic-island">')
expect(source).toContain(
'<Transition name="phone-dynamic-island-content" mode="out-in">',
)
expect(source).toContain(".phone-dynamic-island[data-expanded='true']")
expect(source).toContain("emit('expanded-change', false)")
expect(mainCss).toContain(
'.phone-device--island-expanded .phone-notification',
)
expect(source).toContain('~ .phone-notification-provider')
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
})
it('renders below the top edge and above the physical camera frame', () => {
expect(source).toMatch(
/\.phone-dynamic-island\s*\{[^}]*z-index:\s*102;[^}]*top:\s*30px;/s,
)
expect(mainCss).not.toMatch(/\.phone-dynamic-island\s*\{/)
})
it('keeps compact and expanded islands close to the physical camera proportions', () => {
expect(source).toMatch(
/\.phone-dynamic-island\s*\{[^}]*width:\s*126px;[^}]*height:\s*38px;/s,
)
expect(source).toMatch(
/\.phone-dynamic-island\[data-expanded='true'\]\s*\{[^}]*width:\s*318px;[^}]*height:\s*74px;/s,
)
expect(source).toMatch(
/\.phone-dynamic-island--incoming-call\[data-expanded='true'\]\s*\{[^}]*height:\s*68px;/s,
)
expect(source).toMatch(
/\.phone-dynamic-island--music\[data-expanded='true'\]\s*\{[^}]*width:\s*316px;[^}]*height:\s*150px;/s,
)
expect(source).toMatch(
/\.phone-dynamic-island--stopwatch\[data-expanded='true'\]\s*\{[^}]*height:\s*70px;/s,
)
expect(source).toContain('box-sizing: border-box')
expect(source).toContain('padding: 8px 16px 8px 10px')
})
})
File diff suppressed because it is too large Load Diff
+13 -10
View File
@@ -54,7 +54,6 @@ let metadata: MemoRecordingMetadata = { note: '', pinned: false, title: '' }
let currentState: MemoRecorderStateName = 'idle'
let currentElapsedMs = 0
let currentCorrelationId = ''
let recordingDeviceImei = ''
let removeRecorderErrorListener: (() => void) | null = null
function postRecorderState(state: MemoRecorderStateName, error?: string): void {
@@ -154,7 +153,6 @@ function resetRecordingData(): void {
pausedStartedAt = 0
totalPausedMs = 0
currentElapsedMs = 0
recordingDeviceImei = ''
liveLevels = Array(LIVE_LEVEL_SAMPLES).fill(0.08)
}
@@ -191,8 +189,7 @@ function failRecording(error: string, generation = recordingGeneration): void {
}
async function startRecording(data: Record<string, unknown>): Promise<void> {
const deviceImei = phone.device?.imei
if (!phone.isOpen || !deviceImei) {
if (!phone.isOpen) {
console.error('[Memos] Cannot start a recording while the phone is closed.')
return
}
@@ -220,7 +217,6 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
metadata = { note: '', pinned: false, title: '' }
updateMetadata(data)
resetRecordingData()
recordingDeviceImei = deviceImei
postRecorderState('starting')
try {
const acquiredStream = await navigator.mediaDevices.getUserMedia({
@@ -344,7 +340,6 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
if (generation !== recordingGeneration) return
const waveform = compressedWaveform()
const finalMetadata = { ...metadata }
const finalDeviceImei = recordingDeviceImei
const exceededSizeLimit = recordingTooLarge
cleanupRecorder(false)
resetRecordingData()
@@ -360,7 +355,6 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
liveLevels = waveform.slice(-LIVE_LEVEL_SAMPLES)
const uploadData = {
correlationId,
deviceImei: finalDeviceImei,
durationMs,
mimeType,
note: finalMetadata.note,
@@ -575,9 +569,18 @@ function onMessage(event: MessageEvent): void {
onMounted(() => window.addEventListener('message', onMessage))
watch(
() => phone.device?.imei ?? null,
(imei, previousImei) => {
if (previousImei && imei !== previousImei) cancelRecording()
() =>
[
phone.isOpen,
phone.device?.imei ?? null,
phone.deviceSessionToken,
] as const,
([isOpen, imei, sessionToken], previous) => {
const deviceSessionChanged =
previous !== undefined &&
previous[0] &&
(previous[1] !== imei || previous[2] !== sessionToken)
if (!isOpen || deviceSessionChanged) cancelRecording()
},
)
+1 -1
View File
@@ -210,7 +210,7 @@ onBeforeUnmount(() => {
z-index: 40;
display: flex;
pointer-events: none;
font-family: var(--sky-font-family);
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
.radio-hud[data-horizontal='left'] {
@@ -677,7 +677,9 @@ onBeforeUnmount(() => {
backdrop-filter: blur(26px) saturate(125%);
-webkit-backdrop-filter: blur(26px) saturate(125%);
cursor: pointer;
font-family: var(--sky-font-family);
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
'Segoe UI', sans-serif;
user-select: none;
-webkit-user-select: none;
touch-action: none;
-1
View File
@@ -748,7 +748,6 @@ export function getPhoneAppLabel(
}
export function isPhoneAppRemovable(app: PhoneAppDefinition): boolean {
if (app.adminOnly) return false
return app.kind === 'external'
? app.removable && !app.defaultInstalled
: !DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
+1 -6
View File
@@ -1,5 +1,3 @@
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
export type MemorySound = 'flip' | 'match' | 'mismatch' | 'win'
type Tone = {
@@ -48,10 +46,7 @@ export function playMemorySound(sound: MemorySound, enabled: boolean): void {
oscillator.type = tone.type
oscillator.frequency.setValueAtTime(tone.frequency, start)
gain.gain.setValueAtTime(0.0001, start)
gain.gain.exponentialRampToValueAtTime(
Math.max(0.0001, tone.volume * getPhoneOutputVolume()),
start + 0.012,
)
gain.gain.exponentialRampToValueAtTime(tone.volume, start + 0.012)
gain.gain.exponentialRampToValueAtTime(0.0001, end)
oscillator.connect(gain)
gain.connect(audioContext.destination)
@@ -1,5 +1,3 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import flagUrl from '@/assets/audio/minesweeper/flag.wav?url'
import clearUrl from '@/assets/audio/minesweeper/clear.wav?url'
import mineUrl from '@/assets/audio/minesweeper/mine.wav?url'
@@ -30,7 +28,7 @@ function getPlayers(sound: MinesweeperSound): HTMLAudioElement[] {
if (existing) return existing
const players = Array.from({ length: 3 }, () => {
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
const player = new Audio(soundUrls[sound])
player.preload = 'auto'
player.volume = 0.82
return player
@@ -1,5 +1,3 @@
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
export type NeonDropSound =
| 'clear'
| 'drop'
@@ -50,10 +48,7 @@ function playSequence(sound: NeonDropSound): void {
oscillator.type = type
oscillator.frequency.setValueAtTime(frequency, start + delay)
gain.gain.setValueAtTime(0.0001, start + delay)
gain.gain.exponentialRampToValueAtTime(
Math.max(0.0001, 0.12 * getPhoneOutputVolume()),
start + delay + 0.008,
)
gain.gain.exponentialRampToValueAtTime(0.12, start + delay + 0.008)
gain.gain.exponentialRampToValueAtTime(0.0001, start + delay + duration)
oscillator.connect(gain)
gain.connect(context.destination)
@@ -1,5 +1,3 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import gameOverUrl from '@/assets/audio/number-merge/game-over.wav?url'
import mergeUrl from '@/assets/audio/number-merge/merge.wav?url'
import moveUrl from '@/assets/audio/number-merge/move.wav?url'
@@ -20,7 +18,7 @@ function getPlayers(sound: NumberMergeSound): HTMLAudioElement[] {
if (existing) return existing
const players = Array.from({ length: 3 }, () => {
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
const player = new Audio(soundUrls[sound])
player.preload = 'auto'
player.volume = 0.82
return player
@@ -1,5 +1,3 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import crashUrl from '@/assets/audio/sky-flappy/crash.wav?url'
import flapUrl from '@/assets/audio/sky-flappy/flap.wav?url'
import pointUrl from '@/assets/audio/sky-flappy/point.wav?url'
@@ -13,7 +11,7 @@ export function playSkyFlappySound(sound: SkyFlappySound, enabled: boolean): voi
let players = pools.get(sound)
if (!players) {
players = Array.from({ length: 3 }, () => {
const player = registerPhoneMediaElement(new Audio(urls[sound]))
const player = new Audio(urls[sound])
player.preload = 'auto'
player.volume = 0.84
return player
@@ -1,5 +1,3 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import fallUrl from '@/assets/audio/tower-stack/fall.wav?url'
import hitUrl from '@/assets/audio/tower-stack/hit.wav?url'
import perfectUrl from '@/assets/audio/tower-stack/perfect.wav?url'
@@ -20,7 +18,7 @@ function getPlayers(sound: TowerStackSound): HTMLAudioElement[] {
if (existing) return existing
const players = Array.from({ length: 3 }, () => {
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
const player = new Audio(soundUrls[sound])
player.preload = 'auto'
player.volume = 0.84
return player
-37
View File
@@ -1,37 +0,0 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const appSource = readFileSync(new URL('./App.vue', import.meta.url), 'utf8')
const audioSource = readFileSync(
new URL('./utils/phoneAudio.ts', import.meta.url),
'utf8',
)
const mainCss = readFileSync(
new URL('./assets/main.css', import.meta.url),
'utf8',
)
const musicSource = readFileSync(
new URL('./stores/music.ts', import.meta.url),
'utf8',
)
describe('phone audio output contract', () => {
it('applies the hardware volume to app media and live YouTube playback', () => {
expect(appSource).toContain('installPhoneAudioController()')
expect(appSource).toContain('setPhoneOutputVolume(volume / 100)')
expect(appSource).toContain(
'if (radio.data.connected) void radio.setVolume(volume)',
)
expect(audioSource).toContain(
"document.addEventListener('play', onMediaPlay, true)",
)
expect(audioSource).toContain('localVolume * outputVolume')
expect(musicSource).toContain('registerPhoneMediaElement(new Audio())')
expect(musicSource).toContain('store.volume * getPhoneOutputVolume() * 100')
})
it('renders the hardware speaker symbol in grey', () => {
expect(mainCss).toMatch(/\.phone-volume-hud\s*\{[\s\S]*?color:\s*#a9abb2;/)
})
})
+1 -17
View File
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const readResourceFile = (path: string) =>
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8').replace(/\r\n/g, '\n')
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
const readFrontendFile = (path: string) =>
readFileSync(new URL(path, import.meta.url), 'utf8')
@@ -232,22 +232,6 @@ describe('phone inventory contracts', () => {
)
})
it('opens a running live activity with Space without affecting normal gameplay', () => {
const phoneClient = readResourceFile('source/client/main.lua')
expect(phoneClient).toContain(
'RegisterCommand("sky_phone_live_activity_open"',
)
expect(phoneClient).toContain(
'if not live_activity_active or is_open or open_requested then',
)
expect(phoneClient).toContain(
'RegisterKeyMapping(\n "sky_phone_live_activity_open"',
)
expect(phoneClient).toContain('"SPACE"')
expect(phoneClient).toContain('RegisterNUICallback("ui:live-activity"')
})
it('keeps a server-selected unique handset as the preferred hotkey device', () => {
const phoneServer = readResourceFile('source/server/phone.lua')
@@ -29,10 +29,4 @@ describe('neutral phone navigation contract', () => {
expect(navigationSource).toContain('if not installed_apps[normalized_app_id] then')
expect(navigationSource).toContain('if current_app_id ~= normalized_app_id then')
})
it('defers command-driven app routes until setup or device unlock completes', () => {
expect(appSource).toContain('if (setupRequired.value || isLocked.value)')
expect(appSource).toContain('pendingUnlockRoute.value = requestedRoute')
expect(appSource).toContain("void router.replace(requestedRoute ?? '/')")
})
})
-6
View File
@@ -15,12 +15,6 @@ const developmentRoutes: RouteRecordRaw[] = import.meta.env.DEV
name: 'development-sky-ui',
path: '/development/sky-ui/:demo?',
},
{
component: () =>
import('@/views/development/PhoneDynamicIslandGallery.vue'),
name: 'development-dynamic-islands',
path: '/development/dynamic-islands',
},
]
: []
-198
View File
@@ -1,198 +0,0 @@
import { defineStore } from 'pinia'
import type {
AdminAuditEntry,
AdminActivityResponse,
AdminBootstrap,
AdminCallActivity,
AdminCredential,
AdminMessageActivity,
AdminPlayerDetail,
AdminPlayerSummary,
AdminStats,
} from '@/types/admin'
import { nuiCall, type NuiResponse } from '@/utils/nui'
const EMPTY_STATS: AdminStats = { accounts: 0, devices: 0, online: 0 }
export const useAdminStore = defineStore('admin', {
state: () => ({
actionKey: '',
activityKey: '',
audit: [] as AdminAuditEntry[],
detailLoading: false,
error: '',
initialized: false,
loading: false,
players: [] as AdminPlayerSummary[],
revealedCredentials: {} as Record<string, AdminCredential>,
deviceActivity: {} as Record<
string,
{ calls?: AdminCallActivity[]; messages?: AdminMessageActivity[] }
>,
selectedPlayer: null as AdminPlayerDetail | null,
stats: { ...EMPTY_STATS },
}),
actions: {
async load(): Promise<boolean> {
this.loading = true
const response = await nuiCall<AdminBootstrap>('admin:bootstrap')
this.loading = false
if (!response.success || !response.data) {
this.error = response.error ?? 'request_failed'
return false
}
this.players = response.data.players
this.stats = response.data.stats
this.audit = response.data.audit
this.error = ''
this.initialized = true
return true
},
async openPlayer(source: number): Promise<boolean> {
this.detailLoading = true
this.revealedCredentials = {}
const response = await nuiCall<AdminPlayerDetail>('admin:player', {
source,
})
this.detailLoading = false
if (!response.success || !response.data) {
this.error = response.error ?? 'request_failed'
return false
}
this.selectedPlayer = response.data
this.error = ''
return true
},
closePlayer(): void {
this.selectedPlayer = null
this.revealedCredentials = {}
},
async saveApps(
source: number,
imei: string,
revision: number,
changes: Array<{ appId: string; installed: boolean }>,
): Promise<NuiResponse<AdminPlayerDetail>> {
this.actionKey = `${imei}:save`
const response = await nuiCall<AdminPlayerDetail>('admin:save-apps', {
changes,
imei,
revision,
source,
})
this.actionKey = ''
if (response.success && response.data) {
this.selectedPlayer = response.data
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
async revealPassword(
source: number,
imei: string,
): Promise<NuiResponse<AdminCredential>> {
this.actionKey = `${imei}:password`
const response = await nuiCall<AdminCredential>('admin:reveal-password', {
imei,
source,
})
this.actionKey = ''
if (response.success && response.data) {
this.revealedCredentials[imei] = response.data
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
async loadActivity(
source: number,
imei: string,
kind: 'messages' | 'calls',
): Promise<boolean> {
this.activityKey = `${imei}:${kind}`
const response = await nuiCall<AdminActivityResponse>('admin:activity', {
imei,
kind,
source,
})
this.activityKey = ''
if (!response.success || !response.data) {
this.error = response.error ?? 'request_failed'
return false
}
const activity = this.deviceActivity[imei] ?? {}
if (response.data.kind === 'messages') {
activity.messages = response.data.entries
} else {
activity.calls = response.data.entries
}
this.deviceActivity[imei] = activity
this.error = ''
return true
},
async resetPasscode(
source: number,
imei: string,
): Promise<NuiResponse<AdminPlayerDetail>> {
this.actionKey = `${imei}:reset-passcode`
const response = await nuiCall<AdminPlayerDetail>(
'admin:reset-passcode',
{ imei, source },
)
this.actionKey = ''
if (response.success && response.data) {
this.selectedPlayer = response.data
delete this.revealedCredentials[imei]
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
async changeNumber(
source: number,
imei: string,
phoneNumber: string,
): Promise<NuiResponse<AdminPlayerDetail>> {
this.actionKey = `${imei}:change-number`
const response = await nuiCall<AdminPlayerDetail>('admin:change-number', {
imei,
phoneNumber,
source,
})
this.actionKey = ''
if (response.success && response.data) {
this.selectedPlayer = response.data
delete this.deviceActivity[imei]
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
async factoryReset(
source: number,
imei: string,
): Promise<NuiResponse<AdminPlayerDetail>> {
this.actionKey = `${imei}:factory-reset`
const response = await nuiCall<AdminPlayerDetail>('admin:factory-reset', {
imei,
source,
})
this.actionKey = ''
if (response.success && response.data) {
this.selectedPlayer = response.data
delete this.deviceActivity[imei]
delete this.revealedCredentials[imei]
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
},
})
+6 -48
View File
@@ -64,48 +64,6 @@ describe('app store', () => {
expect(apps.isInstalled('snake')).toBe(false)
expect(apps.isInstalled('health')).toBe(true)
expect(apps.isInstalled('citywarn')).toBe(true)
expect(apps.homeLayout.dock).toEqual([
'phone',
'messages',
'camera',
'clock',
])
for (const dockAppId of ['phone', 'messages', 'camera', 'clock']) {
expect(apps.homeLayout.grid).not.toContain(dockAppId)
}
})
it('drops the retired admin app from persisted phone layouts', () => {
const apps = useAppStoreStore()
apps.hydrate({ claimedApps: ['admin'] })
expect(apps.homeLayout.grid).not.toContain('admin')
})
it('migrates current layouts so dock apps are not repeated in the grid', () => {
const apps = useAppStoreStore()
apps.hydrate({
homeLayout: {
dock: ['phone', 'messages', 'camera', 'clock'],
grid: ['phone', 'messages', 'calculator', 'camera', 'clock'],
hidden: [],
pageCount: 1,
version: HOME_LAYOUT_VERSION,
},
})
expect(apps.homeLayout.dock).toEqual([
'phone',
'messages',
'camera',
'clock',
])
for (const dockAppId of ['phone', 'messages', 'camera', 'clock']) {
expect(apps.homeLayout.grid).not.toContain(dockAppId)
}
expect(apps.homeLayout.grid).toContain('calculator')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('removes old automatic apps unless the player installed them', () => {
@@ -362,7 +320,7 @@ describe('app store', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
mocks.phone.saveDeviceNamespace.mockClear()
const sourceIndex = apps.homeLayout.grid.indexOf('calculator')
const sourceIndex = apps.homeLayout.grid.indexOf('phone')
expect(sourceIndex).toBeGreaterThanOrEqual(0)
expect(
@@ -371,7 +329,7 @@ describe('app store', () => {
HOME_GRID_PAGE_SIZE,
]),
).toBe(true)
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('calculator')
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('phone')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
@@ -428,18 +386,18 @@ describe('app store', () => {
mocks.phone.saveDeviceNamespace.mockClear()
const notesIndex = apps.homeLayout.grid.indexOf('notes')
const settingsIndex = apps.homeLayout.grid.indexOf('settings')
const clockIndex = apps.homeLayout.grid.indexOf('clock')
const folderId = apps.createHomeFolder(
'grid',
notesIndex,
'grid',
settingsIndex,
clockIndex,
'Utilities',
)
expect(folderId).toBeTruthy()
expect(getHomeFolder(apps.homeLayout, folderId!)?.apps).toEqual([
'settings',
'clock',
'notes',
])
const mailIndex = apps.homeLayout.grid.indexOf('mail')
@@ -447,7 +405,7 @@ describe('app store', () => {
apps.moveHomeFolderApp(folderId!, 2, 0)
apps.renameHomeFolder(folderId!, 'Work')
expect(getHomeFolder(apps.homeLayout, folderId!)).toMatchObject({
apps: ['mail', 'notes', 'settings'],
apps: ['mail', 'notes', 'clock'],
name: 'Work',
})
+19 -26
View File
@@ -25,7 +25,6 @@ import {
parseHomeLayout,
reflowHomeGridForWidgetChange,
renameHomeFolder,
removeDockGridDuplicates,
removeHomeApp,
restoreHomeApp,
type HomeArea,
@@ -46,7 +45,7 @@ const pendingInstallations = new WeakMap<
>()
function getDefaultGridIds(): LaunchablePhoneAppId[] {
return PHONE_APPS.filter((app) => app.dockOrder === null)
return [...PHONE_APPS]
.sort((a, b) => a.gridOrder - b.gridOrder)
.map((app) => app.id)
}
@@ -58,12 +57,11 @@ function getDefaultDockIds(): LaunchablePhoneAppId[] {
}
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
return PHONE_APPS.filter((app) => {
if (app.adminOnly) return false
return isExternalPhoneApp(app)
return PHONE_APPS.filter((app) =>
isExternalPhoneApp(app)
? app.defaultInstalled
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)
}).map((app) => app.id)
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id),
).map((app) => app.id)
}
function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
@@ -238,15 +236,13 @@ export const useAppStoreStore = defineStore('app-store', {
layoutVersion === 5 ||
layoutVersion === 6
this.claimedApps = Array.isArray(data?.claimedApps)
? data.claimedApps.filter((id): id is LaunchablePhoneAppId => {
if (typeof id !== 'string') return false
const app = getPhoneApp(id)
if (app?.adminOnly) return false
return (
isPhoneAppId(id) ||
(supportsPersistedExternalApps && isValidExternalPhoneAppId(id))
)
})
? data.claimedApps.filter(
(id): id is LaunchablePhoneAppId =>
typeof id === 'string' &&
(isPhoneAppId(id) ||
(supportsPersistedExternalApps &&
isValidExternalPhoneAppId(id))),
)
: []
this.uninstalledApps = Array.isArray(data?.uninstalledApps)
? data.uninstalledApps.filter((id): id is LaunchablePhoneAppId => {
@@ -267,16 +263,12 @@ export const useAppStoreStore = defineStore('app-store', {
getDefaultGridIds(),
getDefaultDockIds(),
)
const parsedHomeLayout = parseHomeLayout(
this.homeLayout = parseHomeLayout(
data?.homeLayout,
defaults,
installedIds,
false,
)
const normalizedHomeLayout = removeDockGridDuplicates(parsedHomeLayout)
const removedDockGridDuplicates =
normalizedHomeLayout !== parsedHomeLayout
this.homeLayout = normalizedHomeLayout
const protectedHiddenAppIds =
this.homeLayout.hidden.filter(isProtectedHomeApp)
for (const appId of protectedHiddenAppIds) {
@@ -300,7 +292,6 @@ export const useAppStoreStore = defineStore('app-store', {
this.hydrated = true
if (
protectedHiddenAppIds.length ||
removedDockGridDuplicates ||
removedLegacyDefaults ||
layoutVersion === 2 ||
layoutVersion === 3 ||
@@ -311,10 +302,9 @@ export const useAppStoreStore = defineStore('app-store', {
}
},
isInstalled(appId: LaunchablePhoneAppId): boolean {
const app = getPhoneApp(appId)
if (app?.adminOnly) return false
if (this.uninstalledApps.includes(appId)) return false
if (this.claimedApps.includes(appId)) return true
const app = getPhoneApp(appId)
if (!app) return false
return isExternalPhoneApp(app)
? app.defaultInstalled
@@ -330,8 +320,11 @@ export const useAppStoreStore = defineStore('app-store', {
getDefaultDockIds(),
)
const previous = JSON.stringify(this.homeLayout)
this.homeLayout = removeDockGridDuplicates(
parseHomeLayout(this.homeLayout, defaults, installedIds, false),
this.homeLayout = parseHomeLayout(
this.homeLayout,
defaults,
installedIds,
false,
)
for (const appId of [...this.homeLayout.hidden]) {
+4 -16
View File
@@ -7,11 +7,6 @@ import type {
MusicTrack,
} from '@/types/music'
import { nuiCall } from '@/utils/nui'
import {
getPhoneOutputVolume,
registerPhoneMediaElement,
subscribePhoneOutputVolume,
} from '@/utils/phoneAudio'
export type YouTubePlayer = {
destroy: () => void
@@ -53,7 +48,7 @@ declare global {
}
}
const audio = registerPhoneMediaElement(new Audio())
const audio = new Audio()
audio.preload = 'auto'
const YOUTUBE_API_TIMEOUT_MS = 12000
let audioBound = false
@@ -64,11 +59,6 @@ let youtubeApiPromise: Promise<YouTubeApi> | null = null
let youtubePlayer: YouTubePlayer | null = null
let youtubeProgressTimer: number | null = null
subscribePhoneOutputVolume((volume) => {
const localVolume = useMusicStore().volume
youtubePlayer?.setVolume(localVolume * volume * 100)
})
export function musicTrackKey(
track: Pick<MusicTrack, 'id' | 'source'>,
): string {
@@ -286,7 +276,7 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
const api = await loadYouTubeApi()
const store = useMusicStore()
if (youtubePlayer) {
youtubePlayer.setVolume(store.volume * getPhoneOutputVolume() * 100)
youtubePlayer.setVolume(store.volume * 100)
youtubePlayer.loadVideoById(videoId)
youtubePlayer.playVideo()
startYoutubeProgress()
@@ -329,9 +319,7 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
},
onReady: (event) => {
youtubePlayer = event.target
event.target.setVolume(
useMusicStore().volume * getPhoneOutputVolume() * 100,
)
event.target.setVolume(useMusicStore().volume * 100)
event.target.playVideo()
startYoutubeProgress()
resolve()
@@ -566,7 +554,7 @@ export const useMusicStore = defineStore('music', {
setVolume(value: number): void {
this.volume = Math.max(0, Math.min(1, value))
audio.volume = this.volume
youtubePlayer?.setVolume(this.volume * getPhoneOutputVolume() * 100)
youtubePlayer?.setVolume(this.volume * 100)
},
stop(): void {
stopActiveMedia()
+36 -439
View File
@@ -805,257 +805,7 @@ const citywarnFallbackLocales = {
},
}
const adminPanelFallbackLocales = {
name: 'Phone Admin',
subtitle: 'Administration',
navigation: 'Admin navigation',
refresh: 'Refresh admin data',
loading: 'Loading protected data...',
tabs: {
overview: 'Overview',
players: 'Players',
devices: 'Devices',
apps: 'Apps',
accounts: 'Accounts',
messages: 'Messages',
calls: 'Calls',
moderation: 'Moderation',
audit: 'Audit',
},
overview: {
eyebrow: 'Server',
title: 'Dashboard',
body: 'Players, devices, apps, and phone data.',
stats: 'Server phone statistics',
online: 'Online',
devices: 'Devices',
accounts: 'Accounts',
audit: 'Audit entries',
control: 'Navigation',
features: 'Modules',
featuresBody: 'Open an administration module.',
recent: 'Recent activity',
playerFeature: 'Identity, finances, job, and duty',
deviceFeature: 'IMEI, SIM, number, and activity',
appFeature: 'Install or remove phone apps',
accountFeature: 'Account access and protected credentials',
messageFeature: 'Review recent SMS activity',
callFeature: 'Review recent call activity',
moderationFeature: 'Reset access, number, or device data',
auditFeature: 'Review sensitive admin actions',
},
appearance: {
eyebrow: 'Appearance',
title: 'Accent color',
body: 'Change the accent across the complete admin workspace.',
colors: {
emerald: 'Emerald',
blue: 'Blue',
violet: 'Violet',
orange: 'Orange',
red: 'Red',
},
},
players: {
eyebrow: 'Active sessions',
title: 'Online players',
online: 'Online now',
empty: 'No players found',
emptyBody: 'Adjust the search or refresh the live player list.',
},
search: {
players: 'Search name, ID, job, or number',
apps: 'Search apps',
clear: 'Clear search',
},
detail: {
character: 'Character profile',
data: 'Player data overview',
cash: 'Cash',
bank: 'Bank',
job: 'Job',
duty: 'Duty',
onDuty: 'On duty',
offDuty: 'Off duty',
identity: 'Identity',
playerData: 'Player data',
identifier: 'Character identifier',
birthdate: 'Birthdate',
grade: 'Job grade',
unknown: 'Unknown',
},
devices: {
eyebrow: 'Device control',
title: 'Phones',
body: 'Inspect every phone assigned to the selected player.',
choose: 'Choose phone',
empty: 'No phone found',
emptyBody: 'This player currently has no phone device that can be managed.',
noNumber: 'No phone number',
noSim: 'No SIM',
imei: 'IMEI',
updated: 'Last activity',
apps: 'Claimed apps',
account: 'Linked account',
},
credentials: {
eyebrow: 'Protected data',
title: 'Credentials',
email: 'iFruit email',
password: 'iFruit password',
reveal: 'Reveal',
copy: 'Copy password',
copied: 'Password copied.',
noAccount: 'No iFruit account is linked to this phone.',
passcode: 'Device passcode',
passcodeHashed: '{length}-digit PIN · securely hashed and not recoverable',
passcodeDisabled: 'No passcode configured',
revealTitle: 'Reveal protected password?',
revealBody:
'This action is server-authorized, rate-limited, and written to the admin audit log.',
cancel: 'Cancel',
confirmReveal: 'Reveal password',
},
apps: {
eyebrow: 'Remote management',
title: 'App access',
description:
'Stage app access for this device. Nothing changes until you save.',
installed: 'Installed',
available: 'Available',
protected: 'System app',
changes: '{count} pending changes',
},
activity: {
protected: 'Protected activity',
messagesTitle: 'Messages',
messagesBody: 'Recent SMS activity for the selected SIM.',
callsTitle: 'Calls',
callsBody: 'Recent call activity for the selected SIM.',
loading: 'Loading activity...',
incoming: 'Incoming',
outgoing: 'Outgoing',
mediaMessage: '{type} message',
noMessages: 'No message activity found.',
noCalls: 'No call activity found.',
status: {
completed: 'Completed',
missed: 'Missed',
rejected: 'Rejected',
busy: 'Busy',
unanswered: 'Unanswered',
cancelled: 'Cancelled',
failed: 'Failed',
ringing: 'Ringing',
},
},
moderation: {
eyebrow: 'Device administration',
title: 'Moderation actions',
body: 'Every action is server-authorized, rate-limited, and audited.',
resetPasscode: 'Reset passcode',
resetPasscodeBody: 'Remove the device PIN and clear failed attempts.',
changeNumber: 'Change number',
changeNumberBody: 'Assign a new unique number to the current SIM.',
factoryReset: 'Factory reset',
factoryResetBody: 'Clear local device data and disconnect the account.',
saveFirst: 'Save or discard pending app changes first.',
phoneNumber: 'New phone number',
phoneNumberPlaceholder: 'Enter the full configured number',
typeToConfirm: 'Type {word} to confirm the factory reset.',
confirmWord: 'RESET',
cancel: 'Cancel',
'reset-passcodeSuccess': 'Passcode reset.',
'change-numberSuccess': 'Phone number changed.',
'factory-resetSuccess': 'Phone factory reset completed.',
dialogs: {
'reset-passcodeTitle': 'Reset device passcode?',
'reset-passcodeBody':
'The player can unlock this phone without the previous PIN afterward.',
'change-numberTitle': 'Change phone number?',
'change-numberBody':
'The new number must match the configured server number format and be unique.',
'factory-resetTitle': 'Factory reset this phone?',
'factory-resetBody':
'This clears local device data, app settings, security, and the linked account. This cannot be undone.',
},
confirm: {
'reset-passcode': 'Reset passcode',
'change-number': 'Change number',
'factory-reset': 'Factory reset',
},
},
editor: {
brand: 'SKY PHONE',
workspace: 'ADMIN',
players: 'Player directory',
audit: 'Audit log',
selectPlayer:
'Select a player to inspect identity, devices, credentials, and app access.',
save: 'Save changes',
saveHint: 'Apply pending changes',
saved: 'Changes saved.',
unsaved: 'Unsaved changes',
close: 'Close admin panel',
refresh: 'Refresh live data',
online: 'LIVE',
profile: 'PROFILE',
financial: 'FINANCIAL',
device: 'DEVICE',
security: 'SECURITY',
noAutoSave: 'Manual save',
noAutoSaveBody: 'Changes stay local until the green check is pressed.',
discardTitle: 'Discard unsaved changes?',
discardBody: 'Your staged app changes have not been saved.',
keepEditing: 'Keep editing',
discard: 'Discard changes',
saveFailed: 'Some changes could not be saved.',
noSelection: 'No player selected',
},
audit: {
eyebrow: 'Accountability',
title: 'Audit trail',
body: 'Sensitive reveals and remote app changes are recorded here.',
empty: 'No admin actions yet',
emptyBody: 'Protected actions will appear here after they are performed.',
by: '{actor} · target ID {target}',
actions: {
grant_app: 'App installed',
revoke_app: 'App removed',
reveal_account_password: 'Password revealed',
view_messages: 'Messages viewed',
view_calls: 'Calls viewed',
reset_passcode: 'Passcode reset',
change_number: 'Phone number changed',
factory_reset: 'Phone factory reset',
},
},
errors: {
not_authorized: 'You do not have access to the admin panel.',
rate_limited: 'Too many admin requests. Please wait.',
player_unavailable: 'That player is no longer online.',
device_not_owned: 'That phone no longer belongs to the selected player.',
invalid_app: 'That app is not registered on the server.',
app_protected: 'This system app cannot be removed.',
revision_conflict:
'The phone changed in the meantime. Refresh and try again.',
account_not_found: 'No iFruit account is linked to this phone.',
invalid_phone_number:
'Enter a phone number in the configured server format.',
phone_number_unchanged: 'This SIM already uses that phone number.',
phone_number_taken: 'That phone number is already assigned.',
no_sim: 'This phone has no SIM that can be changed.',
passcode_not_set: 'This phone has no passcode configured.',
device_not_found: 'This phone no longer exists.',
metadata_unsupported: 'The phone inventory metadata could not be updated.',
invalid_request: 'The admin request was invalid.',
request_failed: 'The admin request failed.',
default: 'The admin panel is temporarily unavailable.',
},
}
const defaultLocales: LocaleTree = {
AdminPanel: adminPanelFallbackLocales,
Apps: {
citywarn: citywarnFallbackLocales,
crypto: cryptoFallbackLocales,
@@ -2682,190 +2432,46 @@ const defaultLocales: LocaleTree = {
'neon-drop': 'Neon block-dropping puzzle',
},
previews: {
citywarn: {
first: 'Live alerts',
second: 'Safety zones',
third: 'Incident updates',
},
crypto: {
first: 'Synthetic markets',
second: 'Portfolio',
third: 'Wallet transfers',
},
health: {
first: 'Activity rings',
second: 'Medical ID',
third: 'Health records',
},
'weazel-news': {
first: 'Top stories',
second: 'Local reports',
third: 'Breaking news',
},
companies: {
first: 'Business directory',
second: 'Job requests',
third: 'Services',
},
music: {
first: 'Now playing',
second: 'Playlists',
third: 'Music library',
},
picstagram: {
first: 'Photo feed',
second: 'Stories',
third: 'Profiles',
},
feather: {
first: 'Short posts',
second: 'Following feed',
third: 'Conversations',
},
fliptok: {
first: 'Video feed',
second: 'Creator tools',
third: 'Trends',
},
flare: {
first: 'Discover people',
second: 'Matches',
third: 'Live moments',
},
calendar: {
first: 'Upcoming events',
second: 'Day planner',
third: 'Reminders',
},
radio: {
first: 'Live channels',
second: 'Team radio',
third: 'Favorites',
},
'local-pages': {
first: 'Local pages',
second: 'Reviews',
third: 'City discovery',
},
crewlink: {
first: 'Crew roster',
second: 'Shared locations',
third: 'Coordination',
},
phone: {
first: 'Recent calls',
second: 'Contacts',
third: 'Voicemail',
},
messages: {
first: 'Conversations',
second: 'Media sharing',
third: 'Quick replies',
},
darkchat: {
first: 'Private chats',
second: 'Secure groups',
third: 'Invitations',
},
garage: {
first: 'Vehicle list',
second: 'Parking locations',
third: 'Valet',
},
house: {
first: 'Property access',
second: 'Residents',
third: 'Management',
},
map: {
first: 'Live navigation',
second: 'Nearby places',
third: 'Route guidance',
},
skyride: {
first: 'Ride booking',
second: 'Driver tracking',
third: 'Trip history',
},
banking: {
first: 'Account balance',
second: 'Transfers',
third: 'Transactions',
},
billing: {
first: 'Open invoices',
second: 'Payment requests',
third: 'Payment history',
},
citywarn: { first: 'Live alerts', second: 'Safety zones', third: 'Incident updates' },
crypto: { first: 'Synthetic markets', second: 'Portfolio', third: 'Wallet transfers' },
health: { first: 'Activity rings', second: 'Medical ID', third: 'Health records' },
'weazel-news': { first: 'Top stories', second: 'Local reports', third: 'Breaking news' },
companies: { first: 'Business directory', second: 'Job requests', third: 'Services' },
music: { first: 'Now playing', second: 'Playlists', third: 'Music library' },
picstagram: { first: 'Photo feed', second: 'Stories', third: 'Profiles' },
feather: { first: 'Short posts', second: 'Following feed', third: 'Conversations' },
fliptok: { first: 'Video feed', second: 'Creator tools', third: 'Trends' },
flare: { first: 'Discover people', second: 'Matches', third: 'Live moments' },
calendar: { first: 'Upcoming events', second: 'Day planner', third: 'Reminders' },
radio: { first: 'Live channels', second: 'Team radio', third: 'Favorites' },
'local-pages': { first: 'Local pages', second: 'Reviews', third: 'City discovery' },
crewlink: { first: 'Crew roster', second: 'Shared locations', third: 'Coordination' },
phone: { first: 'Recent calls', second: 'Contacts', third: 'Voicemail' },
messages: { first: 'Conversations', second: 'Media sharing', third: 'Quick replies' },
darkchat: { first: 'Private chats', second: 'Secure groups', third: 'Invitations' },
garage: { first: 'Vehicle list', second: 'Parking locations', third: 'Valet' },
house: { first: 'Property access', second: 'Residents', third: 'Management' },
map: { first: 'Live navigation', second: 'Nearby places', third: 'Route guidance' },
skyride: { first: 'Ride booking', second: 'Driver tracking', third: 'Trip history' },
banking: { first: 'Account balance', second: 'Transfers', third: 'Transactions' },
billing: { first: 'Open invoices', second: 'Payment requests', third: 'Payment history' },
mail: { first: 'Inbox', second: 'Attachments', third: 'Mailboxes' },
notes: { first: 'Notes', second: 'Checklists', third: 'Pinned ideas' },
memos: {
first: 'Voice recordings',
second: 'Playback',
third: 'Favorites',
},
calculator: {
first: 'Basic calculation',
second: 'Scientific tools',
third: 'History',
},
camera: {
first: 'Photo mode',
second: 'Video capture',
third: 'Zoom controls',
},
memos: { first: 'Voice recordings', second: 'Playback', third: 'Favorites' },
calculator: { first: 'Basic calculation', second: 'Scientific tools', third: 'History' },
camera: { first: 'Photo mode', second: 'Video capture', third: 'Zoom controls' },
clock: { first: 'World clock', second: 'Alarms', third: 'Timers' },
weather: {
first: 'Current weather',
second: 'Hourly forecast',
third: 'Seven-day outlook',
},
photos: {
first: 'Media library',
second: 'Albums',
third: 'Shared media',
},
settings: {
first: 'Device controls',
second: 'Privacy',
third: 'Personalization',
},
weather: { first: 'Current weather', second: 'Hourly forecast', third: 'Seven-day outlook' },
photos: { first: 'Media library', second: 'Albums', third: 'Shared media' },
settings: { first: 'Device controls', second: 'Privacy', third: 'Personalization' },
snake: { first: 'High score', second: 'Speed', third: 'Classic grid' },
memory: {
first: 'Matched pairs',
second: 'Best time',
third: 'Card themes',
},
'number-merge': {
first: 'Highest tile',
second: 'Score',
third: 'Strategy grid',
},
minesweeper: {
first: 'Mine counter',
second: 'Best time',
third: 'Difficulty',
},
'tower-stack': {
first: 'Tower height',
second: 'Perfect drops',
third: 'High score',
},
'sky-flappy': {
first: 'Flight score',
second: 'Best run',
third: 'Obstacles',
},
citymarkt: {
first: 'Listings',
second: 'Categories',
third: 'Saved offers',
},
'neon-drop': {
first: 'Lines cleared',
second: 'Level',
third: 'Neon pieces',
},
memory: { first: 'Matched pairs', second: 'Best time', third: 'Card themes' },
'number-merge': { first: 'Highest tile', second: 'Score', third: 'Strategy grid' },
minesweeper: { first: 'Mine counter', second: 'Best time', third: 'Difficulty' },
'tower-stack': { first: 'Tower height', second: 'Perfect drops', third: 'High score' },
'sky-flappy': { first: 'Flight score', second: 'Best run', third: 'Obstacles' },
citymarkt: { first: 'Listings', second: 'Categories', third: 'Saved offers' },
'neon-drop': { first: 'Lines cleared', second: 'Level', third: 'Neon pieces' },
},
search: {
recommended: 'Recommended',
@@ -5577,15 +5183,6 @@ export const usePhoneStore = defineStore('phone', {
this.cameraLandscape = false
this.isOpen = false
},
setLocale(
lang: string,
locales: LocaleTree,
fallbackLocales: LocaleTree,
): void {
this.lang = lang
this.locales = locales
this.fallbackLocales = fallbackLocales
},
open(payload: PhoneOpenPayload = {}): void {
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
const nextToken = payload.token ?? this.deviceSessionToken
-113
View File
@@ -1,113 +0,0 @@
export type AdminStats = {
accounts: number
devices: number
online: number
}
export type AdminPlayerSummary = {
deviceCount: number
grade: number
identifier: string
job: string
name: string
onDuty: boolean
phoneNumber: string | null
serverName: string
source: number
}
export type AdminDevice = {
account: {
email: string
id: number
passwordAvailable: boolean
} | null
apps: {
claimed: string[]
revision: number
uninstalled: string[]
}
createdAt: string
imei: string
name: string
number: string | null
security: {
enabled: boolean
failedAttempts: number
length: number | null
lockedUntil: number
}
simRegistered: boolean
simType: string | null
updatedAt: string
}
export type AdminPlayerDetail = {
birthdate: string
devices: AdminDevice[]
firstName: string
identifier: string
job: {
grade: number
gradeLabel: string
label: string
name: string
onDuty: boolean
}
lastName: string
money: {
bank: number
cash: number
currency: string
}
name: string
serverName: string
source: number
}
export type AdminAuditEntry = {
action: string
actorName: string
createdAt: string
details: Record<string, unknown>
deviceImei: string | null
id: number
targetIdentifier: string
targetSource: number | null
}
export type AdminBootstrap = {
audit: AdminAuditEntry[]
players: AdminPlayerSummary[]
stats: AdminStats
}
export type AdminCredential = {
email: string
password: string
}
export type AdminMessageActivity = {
body: string
createdAt: string
direction: 'incoming' | 'outgoing'
id: string
messageType: string
otherNumber: string
readAt: string | null
}
export type AdminCallActivity = {
answeredAt: string | null
direction: 'incoming' | 'outgoing'
durationSeconds: number
endedAt: string | null
id: string
otherNumber: string
startedAt: string
status: string
}
export type AdminActivityResponse =
| { entries: AdminMessageActivity[]; kind: 'messages' }
| { entries: AdminCallActivity[]; kind: 'calls' }
-1
View File
@@ -68,7 +68,6 @@ export type AppLaunchOrigin = {
}
type PhoneAppDefinitionBase = {
adminOnly?: boolean
category: PhoneAppCategory
dockOrder: number | null
gridOrder: number
-7
View File
@@ -1,7 +0,0 @@
export type DynamicIslandActivity =
| 'call'
| 'incoming-call'
| 'music'
| 'recording'
| 'stopwatch'
| 'timer'
@@ -1,59 +0,0 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const sourceDirectory = fileURLToPath(new URL('..', import.meta.url))
const tokensSource = readFileSync(new URL('./tokens.css', import.meta.url), 'utf8')
const mainCssSource = readFileSync(
new URL('../assets/main.css', import.meta.url),
'utf8',
)
function styleSources(directory: string): Array<{
file: string
source: string
}> {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name)
if (entry.isDirectory()) return styleSources(path)
if (!/\.(?:css|vue)$/.test(entry.name)) return []
return [{ file: path, source: readFileSync(path, 'utf8') }]
})
}
describe('Inter font contract', () => {
it('bundles normal and italic variable fonts for every supported weight', () => {
expect(
existsSync(new URL('../assets/fonts/InterVariable.woff2', import.meta.url)),
).toBe(true)
expect(
existsSync(
new URL('../assets/fonts/InterVariable-Italic.woff2', import.meta.url),
),
).toBe(true)
expect(tokensSource.match(/@font-face/g)).toHaveLength(2)
expect(tokensSource.match(/font-weight:\s*100 900;/g)).toHaveLength(2)
expect(tokensSource).toContain("--sky-font-family: 'Inter', Arial, sans-serif;")
})
it('applies the shared font to the document and native form controls', () => {
expect(mainCssSource).toMatch(
/:root\s*\{[\s\S]*?font-family:\s*var\(--sky-font-family\);/,
)
expect(mainCssSource).toMatch(
/button,\s*input,\s*textarea,\s*select\s*\{\s*font:\s*inherit;/,
)
})
it('does not bypass the shared token with a generic system UI stack', () => {
const genericSystemStack =
/font-family\s*:\s*(?:-apple-system|BlinkMacSystemFont|system-ui|ui-sans-serif|['"]Segoe UI['"])/
const violations = styleSources(sourceDirectory)
.filter(({ source }) => genericSystemStack.test(source))
.map(({ file }) => file.slice(sourceDirectory.length + 1))
expect(violations).toEqual([])
})
})
+3 -2
View File
@@ -525,8 +525,9 @@
height: var(--sky-widget-label-height);
overflow: hidden;
color: var(--sky-widget-label-color, #fff);
font-family: var(--sky-font-family);
font-size: var(--sky-home-label-font-size);
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
font-size: 11.5px;
font-weight: 500;
letter-spacing: -0.15px;
line-height: var(--sky-widget-label-height);
+4 -20
View File
@@ -1,19 +1,3 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('../assets/fonts/InterVariable.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 100 900;
font-display: swap;
src: url('../assets/fonts/InterVariable-Italic.woff2') format('woff2');
}
:root {
--sky-device-pixel-ratio: 1;
--sky-hairline-scale: 1;
@@ -42,7 +26,9 @@
--sky-font-title: 17px;
--sky-font-medium-title: 24px;
--sky-font-large-title: 34px;
--sky-font-family: 'Inter', Arial, sans-serif;
--sky-font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF UI Text',
'Helvetica Neue', Helvetica, Arial, sans-serif;
--sky-transition-fast: 100ms;
--sky-transition-normal: 200ms;
--sky-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
@@ -69,10 +55,8 @@
--sky-shadow-thumb:
0 0.5px 4px rgba(0, 0, 0, 0.12), 0 6px 13px rgba(0, 0, 0, 0.12);
--sky-glass-highlight-color: rgba(255, 255, 255, 0.5);
--sky-home-label-font-size: 13px;
--sky-home-label-height: 16px;
--sky-widget-label-gap: 5px;
--sky-widget-label-height: var(--sky-home-label-height);
--sky-widget-label-height: 15px;
--sky-widget-radius-small: 23px;
--sky-widget-radius-medium: 25px;
--sky-widget-radius-large: 28px;
+1 -4
View File
@@ -13,10 +13,7 @@ const previewModules = import.meta.glob<string>(
const APP_STORE_PREVIEW_IMAGES = Object.fromEntries(
Object.entries(previewModules).map(([path, imageUrl]) => {
const appId = path
.split('/')
.at(-1)
?.replace(/\.jpg$/, '')
const appId = path.split('/').at(-1)?.replace(/\.jpg$/, '')
if (!appId) throw new Error(`Invalid App Store preview path: ${path}`)
return [appId, imageUrl]
}),
+2 -4
View File
@@ -17,8 +17,7 @@ function escapeRegExp(value: string): string {
describe('App Store preview catalog', () => {
it('contains a real captured screenshot for every built-in store app', () => {
const storeAppIds = PHONE_APPS.filter(
(app) =>
app.kind !== 'external' && app.id !== 'app-store' && !app.adminOnly,
(app) => app.kind !== 'external' && app.id !== 'app-store',
).map((app) => app.id)
expect([...APP_STORE_PREVIEW_IMAGE_IDS].sort()).toEqual(storeAppIds.sort())
@@ -26,8 +25,7 @@ describe('App Store preview catalog', () => {
it('provides specialized preview data for every built-in store app', () => {
const storeAppIds = PHONE_APPS.filter(
(app) =>
app.kind !== 'external' && app.id !== 'app-store' && !app.adminOnly,
(app) => app.kind !== 'external' && app.id !== 'app-store',
).map((app) => app.id)
expect([...PREVIEWABLE_BUILTIN_APP_IDS].sort()).toEqual(
-35
View File
@@ -21,7 +21,6 @@ import {
moveHomeFolderApp,
parseHomeLayout,
reflowHomeGridForWidgetChange,
removeDockGridDuplicates,
removeHomeApp,
renameHomeFolder,
restoreHomeApp,
@@ -79,40 +78,6 @@ describe('home layout', () => {
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
})
it('removes dock apps from the grid and normalizes affected folders', () => {
const layout: HomeLayout = {
...defaults,
dock: [
'phone',
{
apps: ['messages', 'mail'],
id: 'folder-abcdef',
name: 'Dock',
type: 'folder',
},
null,
null,
],
grid: [
'phone',
{
apps: ['messages', 'notes'],
id: 'folder-ghijkl',
name: 'Grid',
type: 'folder',
},
'mail',
'clock',
...Array.from({ length: HOME_GRID_PAGE_SIZE - 4 }, () => null),
],
}
const normalized = removeDockGridDuplicates(layout)
expect(normalized.grid.slice(0, 3)).toEqual(['notes', 'clock', null])
expect(normalized.dock).toEqual(layout.dock)
})
it('migrates compact persisted arrays and appends newly installed apps', () => {
const layout = parseHomeLayout(
{
-31
View File
@@ -505,37 +505,6 @@ export function createDefaultHomeLayout(
}
}
export function removeDockGridDuplicates(layout: HomeLayout): HomeLayout {
const dockAppIds = new Set<LaunchablePhoneAppId>()
for (const item of layout.dock) {
if (typeof item === 'string') dockAppIds.add(item)
if (isHomeFolder(item)) {
for (const appId of item.apps) dockAppIds.add(appId)
}
}
if (!dockAppIds.size) return layout
let changed = false
const grid = layout.grid.map((item): HomeSlot => {
if (typeof item === 'string') {
if (!dockAppIds.has(item)) return item
changed = true
return null
}
if (!isHomeFolder(item)) return null
const apps = item.apps.filter((appId) => !dockAppIds.has(appId))
if (apps.length === item.apps.length) return cloneItem(item)
changed = true
return normalizeFolder({ ...item, apps })
})
if (!changed) return layout
const next = cloneLayout(layout)
next.grid = compactGridPages(grid)
return next
}
export function parseHomeLayout(
value: unknown,
defaults: HomeLayout,
@@ -1,5 +1,4 @@
import { describe, expect, it } from 'vitest'
import { runInNewContext } from 'node:vm'
import type { ExternalPhoneAppDefinition } from '@/types/apps'
import {
@@ -140,79 +139,6 @@ describe('LB Phone app bridge', () => {
expect(() => new Function(runtime)).not.toThrow()
})
it('applies the LB iframe layout contract before revealing vendor apps', () => {
const document = createLbPhoneFrameDocument(
'<!doctype html><html><head></head><body style="visibility:hidden"></body></html>',
{
appName: 'radio-app',
localStorage: {},
resourceName: 'lb-radioapp',
settings: createLbPhoneHostSettings({
deviceName: 'Main phone',
isDarkMode: true,
language: 'en',
preferences: DEFAULT_PHONE_PREFERENCES,
securityEnabled: false,
}),
ui: 'https://cfx-nui-lb-radioapp/ui/dist/index.html',
},
)
const runtime = /<script>([\s\S]*?)<\/script>/i.exec(document)?.[1]
expect(runtime).toBeTruthy()
const messageListeners: Array<(event: { data: unknown }) => void> = []
const readyListeners: Array<() => void> = []
const documentElement = { dataset: {}, style: {} }
const body = {
dataset: {},
style: { visibility: 'hidden' },
}
const sandbox = {
addEventListener(
eventName: string,
listener: (event: { data: unknown }) => void,
) {
if (eventName === 'message') messageListeners.push(listener)
},
componentsLoaded: undefined as boolean | undefined,
console,
document: {
addEventListener(eventName: string, listener: () => void) {
if (eventName === 'DOMContentLoaded') readyListeners.push(listener)
},
body,
documentElement,
},
parent: { postMessage() {} },
}
runInNewContext(runtime ?? '', sandbox)
expect(body.style.visibility).toBe('hidden')
expect(readyListeners).toHaveLength(1)
readyListeners[0]?.()
expect(documentElement.style).toMatchObject({
height: '100%',
margin: '0',
padding: '0',
width: '100%',
})
expect(body.dataset).toMatchObject({ device: 'phone', theme: 'dark' })
expect(body.style).toMatchObject({
height: '100%',
margin: '0',
padding: '0',
visibility: 'visible',
width: '100%',
})
body.style.visibility = 'hidden'
expect(messageListeners).toHaveLength(1)
messageListeners[0]?.({ data: 'componentsLoaded' })
expect(body.style.visibility).toBe('visible')
expect(sandbox.componentsLoaded).toBe(true)
})
it('persists isolated LB localStorage snapshots without app changes', () => {
const values = new Map<string, string>()
const storage = {
+1 -28
View File
@@ -130,25 +130,6 @@ function applySettings(nextSettings) {
if (document.body) document.body.dataset.theme = theme;
}
function prepareDocument() {
Object.assign(document.documentElement.style, {
height: '100%',
margin: '0',
padding: '0',
width: '100%'
});
if (!document.body) return;
document.body.dataset.device = 'phone';
Object.assign(document.body.style, {
height: '100%',
margin: '0',
padding: '0',
visibility: 'visible',
width: '100%'
});
}
globalThis.resourceName = config.resourceName;
globalThis.appName = config.appName;
globalThis.components = globalThis.components ?? {};
@@ -208,11 +189,6 @@ globalThis.getSettings = async () => globalThis.settings;
globalThis.addEventListener('message', (event) => {
const message = event.data;
if (message === 'componentsLoaded') {
globalThis.componentsLoaded = true;
prepareDocument();
return;
}
if (!message || typeof message !== 'object') return;
if (message.type === 'sky-phone:lb-settings') {
@@ -234,10 +210,7 @@ globalThis.addEventListener('message', (event) => {
});
applySettings(config.settings);
document.addEventListener('DOMContentLoaded', () => {
prepareDocument();
applySettings(globalThis.settings);
}, { once: true });
document.addEventListener('DOMContentLoaded', () => applySettings(globalThis.settings), { once: true });
`
function escapeAttribute(value: string): string {
-96
View File
@@ -1,96 +0,0 @@
type PhoneAudioVolumeListener = (volume: number) => void
const mediaElements = new Map<HTMLMediaElement, boolean>()
const mediaLocalVolumes = new WeakMap<HTMLMediaElement, number>()
const volumeListeners = new Set<PhoneAudioVolumeListener>()
let documentListenerReferences = 0
let outputVolume = 1
function clampVolume(volume: number): number {
return Math.max(0, Math.min(1, Number.isFinite(volume) ? volume : 0))
}
function applyMediaVolume(element: HTMLMediaElement): void {
const localVolume = mediaLocalVolumes.get(element) ?? element.volume
const nextVolume = clampVolume(localVolume * outputVolume)
if (Math.abs(element.volume - nextVolume) < 0.001) return
element.volume = nextVolume
}
function onMediaVolumeChange(event: Event): void {
const element = event.currentTarget as HTMLMediaElement
const currentLocalVolume = mediaLocalVolumes.get(element) ?? element.volume
const expectedVolume = clampVolume(currentLocalVolume * outputVolume)
if (Math.abs(element.volume - expectedVolume) < 0.001) return
mediaLocalVolumes.set(element, clampVolume(element.volume))
applyMediaVolume(element)
}
function onMediaPlay(event: Event): void {
if (event.target instanceof HTMLMediaElement) {
trackPhoneMediaElement(event.target, false)
}
}
function trackPhoneMediaElement<T extends HTMLMediaElement>(
element: T,
persistent: boolean,
): T {
if (mediaElements.has(element)) {
if (persistent) mediaElements.set(element, true)
return element
}
mediaElements.set(element, persistent)
mediaLocalVolumes.set(element, clampVolume(element.volume))
element.addEventListener('volumechange', onMediaVolumeChange)
applyMediaVolume(element)
return element
}
export function getPhoneOutputVolume(): number {
return outputVolume
}
export function installPhoneAudioController(): () => void {
documentListenerReferences += 1
if (documentListenerReferences === 1) {
document.addEventListener('play', onMediaPlay, true)
document
.querySelectorAll<HTMLMediaElement>('audio, video')
.forEach((element) => trackPhoneMediaElement(element, false))
}
return () => {
documentListenerReferences = Math.max(0, documentListenerReferences - 1)
if (documentListenerReferences === 0) {
document.removeEventListener('play', onMediaPlay, true)
}
}
}
export function registerPhoneMediaElement<T extends HTMLMediaElement>(
element: T,
): T {
return trackPhoneMediaElement(element, true)
}
export function setPhoneOutputVolume(volume: number): void {
outputVolume = clampVolume(volume)
for (const [element, persistent] of mediaElements) {
if (!persistent && !element.isConnected && element.paused) {
element.removeEventListener('volumechange', onMediaVolumeChange)
mediaElements.delete(element)
continue
}
applyMediaVolume(element)
}
for (const listener of volumeListeners) listener(outputVolume)
}
export function subscribePhoneOutputVolume(
listener: PhoneAudioVolumeListener,
): () => void {
volumeListeners.add(listener)
return () => volumeListeners.delete(listener)
}
@@ -1,174 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
normalizePhoneViewportRect,
readPhoneViewportGeometry,
type PhoneViewportRect,
} from '@/utils/phoneViewportGeometry'
type RectMeasurement = Pick<
DOMRectReadOnly,
'height' | 'left' | 'top' | 'width'
>
function measuredRect(
left: number,
top: number,
width: number,
height: number,
): RectMeasurement {
return { height, left, top, width }
}
function expectRectClose(
actual: PhoneViewportRect,
expected: PhoneViewportRect,
): void {
for (const key of [
'bottom',
'height',
'left',
'right',
'top',
'width',
] as const) {
expect(actual[key]).toBeCloseTo(expected[key], 6)
}
}
function createGeometryFixture(options: {
canvasOffsetHeight: number
canvasOffsetWidth: number
rawCanvasRect: RectMeasurement
wrapperRect: RectMeasurement
}): { anchor: Element; element: (rect: RectMeasurement) => Element } {
const wrapper = {
getBoundingClientRect: () => options.wrapperRect,
} as unknown as HTMLElement
const canvas = {
closest: (selector: string) =>
selector === '.phone-resolution-wrapper' ? wrapper : null,
getBoundingClientRect: () => options.rawCanvasRect,
offsetHeight: options.canvasOffsetHeight,
offsetWidth: options.canvasOffsetWidth,
} as unknown as HTMLElement
return {
anchor: {
closest: (selector: string) =>
selector === '.phone-resolution-canvas' ? canvas : null,
} as unknown as Element,
element: (rect) =>
({ getBoundingClientRect: () => rect }) as unknown as Element,
}
}
describe('phone viewport geometry', () => {
it('leaves modern Chrome measurements unchanged when the canvas BCR is already rendered', () => {
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
const layer = measuredRect(1589.783336, 177.75, 289.533328, 603.2)
expectRectClose(normalizePhoneViewportRect(layer, wrapper, wrapper), {
bottom: layer.top + layer.height,
height: layer.height,
left: layer.left,
right: layer.left + layer.width,
top: layer.top,
width: layer.width,
})
})
it('calibrates live CEF 103 BCRs back inside the visible wrapper', () => {
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
const rawCanvas = measuredRect(1899.87, 152.5, 389.98, 844)
const rawLayer = measuredRect(1920.03, 214.25, 349.66, 728.5)
const corrected = normalizePhoneViewportRect(rawLayer, rawCanvas, wrapper)
expect(corrected.left).toBeCloseTo(1589.7833360685163, 6)
expect(corrected.width).toBeCloseTo(289.5333278629674, 6)
expect(corrected.right).toBeCloseTo(1879.3166639314836, 6)
expect(corrected.left).toBeGreaterThan(wrapper.left)
expect(corrected.right).toBeLessThan(wrapper.left + wrapper.width)
})
it.each([
['80%', 0.6624],
['100%', 0.828],
['120%', 0.9936],
])(
'normalizes fractional positions, sizes, and deltas at %s scaling',
(_label, zoom) => {
const rawCanvas = measuredRect(1900.125, 212.75, 390, 844)
const wrapper = measuredRect(1530.5, 250.25, 390 * zoom, 844 * zoom)
const rawStart = measuredRect(
rawCanvas.left + 20.125,
rawCanvas.top + 50.375,
349.75,
73.125,
)
const rawEnd = measuredRect(
rawStart.left + 73.25,
rawStart.top - 41.75,
rawStart.width,
rawStart.height,
)
const start = normalizePhoneViewportRect(rawStart, rawCanvas, wrapper)
const end = normalizePhoneViewportRect(rawEnd, rawCanvas, wrapper)
expect(start.left).toBeCloseTo(wrapper.left + 20.125 * zoom, 6)
expect(start.top).toBeCloseTo(wrapper.top + 50.375 * zoom, 6)
expect(start.width).toBeCloseTo(349.75 * zoom, 6)
expect(start.height).toBeCloseTo(73.125 * zoom, 6)
expect(end.left - start.left).toBeCloseTo(73.25 * zoom, 6)
expect(end.top - start.top).toBeCloseTo(-41.75 * zoom, 6)
},
)
it('reads visual scale and normalized element rects from a canvas anchor', () => {
const fixture = createGeometryFixture({
canvasOffsetHeight: 844,
canvasOffsetWidth: 390,
rawCanvasRect: measuredRect(1899.87, 152.5, 389.98, 844),
wrapperRect: measuredRect(1573.09, 126.25, 322.92, 698.832),
})
const geometry = readPhoneViewportGeometry(fixture.anchor)
const layer = fixture.element(measuredRect(1920.03, 214.25, 349.66, 728.5))
expect(geometry).not.toBeNull()
expect(geometry?.scaleX).toBeCloseTo(0.828, 6)
expect(geometry?.scaleY).toBeCloseTo(0.828, 6)
expect(geometry?.rect(layer).left).toBeCloseTo(1589.7833360685163, 6)
})
it('uses finite identity fallbacks for zero geometry and missing anchors', () => {
const rawCanvas = measuredRect(100, 50, 0, 0)
const corrected = normalizePhoneViewportRect(
measuredRect(112.5, 58.25, 40, 20),
rawCanvas,
measuredRect(500, 300, 0, 0),
)
const fixture = createGeometryFixture({
canvasOffsetHeight: 0,
canvasOffsetWidth: 0,
rawCanvasRect: rawCanvas,
wrapperRect: measuredRect(500, 300, 0, 0),
})
const geometry = readPhoneViewportGeometry(fixture.anchor)
expect(corrected).toEqual({
bottom: 328.25,
height: 20,
left: 512.5,
right: 552.5,
top: 308.25,
width: 40,
})
expect(Object.values(corrected).every(Number.isFinite)).toBe(true)
expect(geometry?.scaleX).toBe(1)
expect(geometry?.scaleY).toBe(1)
expect(readPhoneViewportGeometry(null)).toBeNull()
expect(
readPhoneViewportGeometry({ closest: () => null } as unknown as Element),
).toBeNull()
})
})
@@ -1,73 +0,0 @@
export type PhoneViewportRect = {
bottom: number
height: number
left: number
right: number
top: number
width: number
}
type RectMeasurement = Pick<
DOMRectReadOnly,
'height' | 'left' | 'top' | 'width'
>
export type PhoneViewportGeometry = {
readonly scaleX: number
readonly scaleY: number
rect(element: Element): PhoneViewportRect
}
function positiveRatio(numerator: number, denominator: number): number {
return Number.isFinite(numerator) &&
Number.isFinite(denominator) &&
numerator > 0 &&
denominator > 0
? numerator / denominator
: 1
}
export function normalizePhoneViewportRect(
rawRect: RectMeasurement,
rawCanvasRect: RectMeasurement,
wrapperRect: RectMeasurement,
): PhoneViewportRect {
const factorX = positiveRatio(wrapperRect.width, rawCanvasRect.width)
const factorY = positiveRatio(wrapperRect.height, rawCanvasRect.height)
const left = wrapperRect.left + (rawRect.left - rawCanvasRect.left) * factorX
const top = wrapperRect.top + (rawRect.top - rawCanvasRect.top) * factorY
const width = rawRect.width * factorX
const height = rawRect.height * factorY
return {
bottom: top + height,
height,
left,
right: left + width,
top,
width,
}
}
export function readPhoneViewportGeometry(
anchor: Element | null,
): PhoneViewportGeometry | null {
const canvas = anchor?.closest<HTMLElement>('.phone-resolution-canvas')
const wrapper = canvas?.closest<HTMLElement>('.phone-resolution-wrapper')
if (!canvas || !wrapper) return null
const rawCanvasRect = canvas.getBoundingClientRect()
const wrapperRect = wrapper.getBoundingClientRect()
return {
scaleX: positiveRatio(wrapperRect.width, canvas.offsetWidth),
scaleY: positiveRatio(wrapperRect.height, canvas.offsetHeight),
rect(element: Element): PhoneViewportRect {
return normalizePhoneViewportRect(
element.getBoundingClientRect(),
rawCanvasRect,
wrapperRect,
)
},
}
}
@@ -140,56 +140,4 @@ describe('springboard widget drag', () => {
expect(delta.x).toBeCloseTo(100)
expect(delta.y).toBeCloseTo(200)
})
it.each([
['80% preview', 0.6624],
['80% production clamp', 2 / 3],
['100%', 0.828],
['120%', 0.9936],
])('keeps the rendered drag delta 1:1 at %s zoom', (_label, zoom) => {
const layoutWidth = 350
const layoutHeight = 808
const viewportLeft = 128.25
const viewportTop = 64.5
const viewportWidth = layoutWidth * zoom
const viewportHeight = layoutHeight * zoom
const pointerStart = {
x: viewportLeft + 48.5 * zoom,
y: viewportTop + 132.25 * zoom,
}
const viewportDelta = { x: 73.25, y: -41.75 }
const start = springboardViewportToLocal(
pointerStart.x,
pointerStart.y,
viewportLeft,
viewportTop,
viewportWidth,
viewportHeight,
layoutWidth,
layoutHeight,
)
const end = springboardViewportToLocal(
pointerStart.x + viewportDelta.x,
pointerStart.y + viewportDelta.y,
viewportLeft,
viewportTop,
viewportWidth,
viewportHeight,
layoutWidth,
layoutHeight,
)
const localDelta = springboardViewportDeltaToLocal(
viewportDelta.x,
viewportDelta.y,
viewportWidth,
viewportHeight,
layoutWidth,
layoutHeight,
)
expect((end.x - start.x) * zoom).toBeCloseTo(viewportDelta.x, 6)
expect((end.y - start.y) * zoom).toBeCloseTo(viewportDelta.y, 6)
expect(localDelta.x * zoom).toBeCloseTo(viewportDelta.x, 6)
expect(localDelta.y * zoom).toBeCloseTo(viewportDelta.y, 6)
})
})
+1 -5
View File
@@ -1,5 +1,3 @@
import { readPhoneViewportGeometry } from '@/utils/phoneViewportGeometry'
export type PageTurnDirection = -1 | 0 | 1
export type SpringboardEdgeTurn = {
@@ -41,9 +39,7 @@ export function readSpringboardDragMetrics(
'.springboard-page, .home-folder-panel',
)
if (!surface) return null
const bounds =
readPhoneViewportGeometry(surface)?.rect(surface) ??
surface.getBoundingClientRect()
const bounds = surface.getBoundingClientRect()
return {
layoutHeight: surface.offsetHeight,
layoutWidth: surface.offsetWidth,
+1 -2
View File
@@ -32,7 +32,7 @@ describe('phone tones', () => {
}> = []
vi.stubGlobal(
'Audio',
class extends EventTarget {
class {
currentTime = 7
loop = false
pause = pause
@@ -42,7 +42,6 @@ describe('phone tones', () => {
volume = 0
constructor(src: string) {
super()
this.src = src
players.push(this)
}
+2 -3
View File
@@ -1,4 +1,3 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import type { AlarmSoundId } from '@/utils/alarms'
import type { NotificationSoundId } from '@/utils/preferences'
@@ -496,8 +495,8 @@ export function playPhoneVibration(
kind: PhoneVibrationKind,
loop: boolean,
): () => void {
const player = registerPhoneMediaElement(
new Audio(`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`),
const player = new Audio(
`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`,
)
let stopped = false
player.loop = loop
+1 -1
View File
@@ -28,7 +28,7 @@ const launchStyle = computed(() => {
<template>
<div
v-if="app && !app.adminOnly"
v-if="app"
class="app-window"
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
:style="launchStyle"
@@ -19,31 +19,11 @@ const mainCss = readFileSync(
new URL('../assets/main.css', import.meta.url),
'utf8',
)
const tokensCss = readFileSync(
new URL('../ui/tokens.css', import.meta.url),
'utf8',
)
const foundationCss = readFileSync(
new URL('../ui/foundation.css', import.meta.url),
'utf8',
)
const builtInWallpaperCss = mainCss.slice(
mainCss.indexOf('.wallpaper--midnight'),
mainCss.indexOf('.wallpaper--custom'),
)
describe('Springboard page swipe contract', () => {
it('keeps app and widget labels on the larger shared home typography', () => {
expect(tokensCss).toContain('--sky-home-label-font-size: 13px;')
expect(tokensCss).toContain('--sky-home-label-height: 16px;')
expect(mainCss).toMatch(
/\.app-icon-label\s*\{[\s\S]*?font-size:\s*var\(--sky-home-label-font-size\);/,
)
expect(foundationCss).toMatch(
/\.sky-widget-frame__label\s*\{[\s\S]*?font-size:\s*var\(--sky-home-label-font-size\);/,
)
})
it('keeps the built-in wallpapers visually restrained', () => {
expect(builtInWallpaperCss).not.toMatch(/(?:conic|repeating-\w+)-gradient/)
expect(builtInWallpaperCss.match(/radial-gradient/g)).toHaveLength(12)
@@ -114,99 +94,36 @@ describe('Springboard page swipe contract', () => {
expect(folderIconSource).toContain(':style="dragPointerStyle"')
})
it('renders the home drag visual through the unzoomed phone portal', () => {
expect(viewSource).toContain('source.cloneNode(true)')
it('uses the same scale-aware in-phone drag path as widgets', () => {
expect(viewSource).not.toContain('source.cloneNode(true)')
expect(viewSource).not.toContain('<Teleport to="body">')
expect(viewSource).not.toContain(':external-drag-visual')
expect(viewSource).toContain(
'<Teleport defer to="#phone-home-drag-portal">',
"'springboard--home-dragging': draggingHomeApp !== null",
)
expect(viewSource).toContain(
'<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>',
)
expect(viewSource).toContain('readPhoneViewportGeometry')
expect(viewSource).toMatch(/const sourceBounds = \w+\.rect\(source\)/)
expect(viewSource).toMatch(/const clipBounds = \w+\.rect\(\w+\)/)
expect(viewSource).not.toContain('homeDragLocalPoint')
expect(viewSource).not.toContain('springboardViewportToLocal')
expect(viewSource).toContain("position.className = 'home-drag-position'")
expect(viewSource).toMatch(
/homeDragGrip = \{\s*x: event\.clientX - sourceBounds\.left,\s*y: event\.clientY - sourceBounds\.top,/,
)
expect(viewSource).toMatch(
/ghost\.style\.transform = `scale\(\$\{\w+\.scaleX\}, \$\{\w+\.scaleY\}\)`/,
)
expect(
viewSource.match(/:external-drag-visual="homeDragVisualActive"/g),
).toHaveLength(4)
expect(viewSource).toContain('updateHomeDragGhost(event)')
expect(viewSource).toContain('const dropGhost = homeDragGhost')
expect(viewSource).toContain('const dropOrigin = homeDragPreviewBounds')
expect(viewSource).not.toContain('dropGhost?.getBoundingClientRect()')
expect(viewSource).toContain('clearHomeDragGhost(dropGhost)')
expect(viewSource).not.toContain('draggedElement?.getBoundingClientRect()')
expect(viewSource).not.toContain('springboard--home-dragging')
expect(appIconSource).toContain('externalDragVisual?: boolean')
expect(folderIconSource).toContain('externalDragVisual?: boolean')
expect(appIconSource).toContain('app-icon-item--drag-source')
expect(folderIconSource).toContain('app-icon-item--drag-source')
expect(viewSource).toContain('draggedElement?.getBoundingClientRect()')
expect(appIconSource).not.toContain('externalDragVisual')
expect(folderIconSource).not.toContain('externalDragVisual')
expect(mainCss).not.toContain('.home-drag-layer')
expect(mainCss).not.toContain('.home-drag-ghost')
expect(mainCss).not.toContain('.app-icon-item--drag-source')
expect(mainCss).toMatch(
/\.phone-home-drag-portal\s*\{[^}]*pointer-events:\s*none;/s,
/\.springboard--home-dragging \.springboard-page--apps\s*\{[^}]*overflow:\s*visible;/s,
)
expect(mainCss).toContain('.home-drag-position')
expect(mainCss).toContain('.home-drag-ghost')
expect(mainCss).toContain('.app-icon-item--drag-source')
expect(mainCss).toMatch(/\.springboard\s*\{[\s\S]*?overflow:\s*hidden;/)
expect(mainCss).toMatch(
/\.springboard-page--apps\s*\{[^}]*overflow:\s*hidden;/s,
)
})
it('cleans up the external drag visual on every terminal path', () => {
expect(
viewSource.match(
/@dragstart="startHomeDrag\('(grid|dock)', [^,]+, \$event\)"/g,
),
).toHaveLength(4)
expect(viewSource.match(/@dragcancel="stopHomeDrag"/g)).toHaveLength(4)
expect(viewSource).toContain(
'if (expectedGhost && homeDragGhost !== expectedGhost) return',
)
expect(viewSource).toMatch(
/if \(!dragged\) \{\s*clearHomeDragGhost\(\)\s*return/,
)
expect(viewSource).toMatch(
/animateHomeItemDrop\(\s*draggedItem,\s*dropArea,\s*dropIndex,\s*dropOrigin,?\s*\)\.finally\(/,
)
expect(viewSource).toMatch(
/function stopHomeDrag[\s\S]*?clearHomeDragGhost\(\)/,
)
expect(viewSource).toMatch(
/if \(folderId\) \{[\s\S]*?clearHomeDragGhost\(\)/,
)
expect(viewSource).toMatch(
/onBeforeUnmount\(\(\) => \{[\s\S]*?clearHomeDragGhost\(\)/,
)
})
it('targets the active visual page and lets grid or dock drags turn pages', () => {
expect(viewSource).toContain(':data-home-page="page.page"')
expect(viewSource).toContain(':data-home-target-offset="cell.targetOffset"')
expect(viewSource).toContain('nearestGridDropTarget')
expect(viewSource).toContain('moveHomeAppToGridPage')
expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'")
expect(viewSource).toContain('event.clientX < springboardBounds.left')
expect(viewSource).toContain('event.clientY > springboardBounds.bottom')
expect(viewSource).toContain('function homeDragViewportRect(')
expect(viewSource).toContain('if (geometry) return geometry.rect(element)')
expect(viewSource).toContain('homeDragViewportRect(pageElement, geometry)')
expect(viewSource).toContain('homeDragViewportRect(springboard, geometry)')
expect(viewSource).toContain('homeDragViewportRect(slot, geometry)')
expect(viewSource).toContain('homeDragViewportRect(dock, geometry)')
expect(viewSource).toMatch(
/homeDragViewportRect\(\s*appElement,\s*readPhoneViewportGeometry\(appElement\),?\s*\)/,
)
expect(viewSource).toContain(
'springboardBounds.left + (bounds.left - pageBounds.left)',
)
expect(viewSource).toContain('event.clientX < pageBounds.left')
expect(viewSource).toContain('event.clientY > pageBounds.bottom')
expect(viewSource).toContain('nearestSpringboardRectIndex')
expect(viewSource).toContain("queueEdgePageTurn(lastHomePointer, 'app')")
expect(viewSource).toContain("edgePageLocked = dragType === 'widget'")
+35 -273
View File
@@ -41,11 +41,6 @@ import {
type HomeItem,
} from '@/utils/homeLayout'
import type { ReorderDirection } from '@/utils/keyboard'
import {
readPhoneViewportGeometry,
type PhoneViewportGeometry,
type PhoneViewportRect,
} from '@/utils/phoneViewportGeometry'
import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
import {
maximumRenderedWidgetPage,
@@ -111,8 +106,6 @@ const draggingHomeApp = ref<{
area: HomeArea
index: number
} | null>(null)
const homeDragLayer = ref<HTMLElement | null>(null)
const homeDragVisualActive = ref(false)
const temporaryHomePage = ref<number | null>(null)
const openedFolderId = ref<string | null>(null)
const folderDraggingOutside = ref(false)
@@ -132,11 +125,6 @@ let edgePageDirection = 0
let edgePageLocked = false
let folderHoverTimer: number | undefined
let lastHomePointer: { clientX: number; clientY: number } | null = null
let homeDragGhost: HTMLElement | null = null
let homeDragGrip: { x: number; y: number } | null = null
let homeDragGeometry: PhoneViewportGeometry | null = null
let homeDragClipBounds: PhoneViewportRect | null = null
let homeDragPreviewBounds: PhoneViewportRect | null = null
const installedApps = computed(() =>
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
@@ -465,7 +453,7 @@ function startWidgetDrag(id: string, event: PointerEvent): void {
).find((candidate) => candidate.dataset.widgetId === id)
if (!widget) return
pageTransitioning.value = false
const bounds = homeDragViewportRect(widget)
const bounds = widget.getBoundingClientRect()
draggingWidgetId.value = id
widgetDragGrip.value = {
x: event.clientX - bounds.left,
@@ -486,7 +474,7 @@ function updateWidgetDragPreview(event: {
)
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
if (!grid || !pageElement) return
const renderedGridBounds = homeDragViewportRect(grid)
const renderedGridBounds = grid.getBoundingClientRect()
const gridStyle = getComputedStyle(grid)
const scaleX =
grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1
@@ -541,7 +529,7 @@ function clearTemporaryHomePage(keepCurrentPage = false): void {
function resolveHomeEdgeTurn(event: { clientX: number }) {
const springboard = document.querySelector<HTMLElement>('.springboard')
if (!springboard) return null
const bounds = homeDragViewportRect(springboard)
const bounds = springboard.getBoundingClientRect()
const renderedLastPage = appPages.value.length
return resolveSpringboardHomeEdgeTurn(
event.clientX,
@@ -560,7 +548,7 @@ function queueEdgePageTurn(
): void {
const springboard = document.querySelector<HTMLElement>('.springboard')
if (!springboard) return
const bounds = homeDragViewportRect(springboard)
const bounds = springboard.getBoundingClientRect()
const turn =
dragType === 'app'
? resolveHomeEdgeTurn(event)
@@ -745,166 +733,10 @@ async function saveWidgetConfig(
if (configured) changePage(configured.page)
}
function homeDragViewportRect(
element: Element,
geometry = homeDragGeometry ?? readPhoneViewportGeometry(element),
): PhoneViewportRect {
if (geometry) return geometry.rect(element)
const bounds = element.getBoundingClientRect()
return {
bottom: bounds.bottom,
height: bounds.height,
left: bounds.left,
right: bounds.right,
top: bounds.top,
width: bounds.width,
}
}
function viewportRectAt(
left: number,
top: number,
width: number,
height: number,
): PhoneViewportRect {
return {
bottom: top + height,
height,
left,
right: left + width,
top,
width,
}
}
function clearHomeDragGhost(expectedGhost?: HTMLElement | null): void {
if (expectedGhost && homeDragGhost !== expectedGhost) return
homeDragGhost?.remove()
homeDragGhost = null
homeDragGrip = null
homeDragGeometry = null
homeDragClipBounds = null
homeDragPreviewBounds = null
homeDragVisualActive.value = false
const layer = homeDragLayer.value
layer?.style.removeProperty('left')
layer?.style.removeProperty('top')
layer?.style.removeProperty('width')
layer?.style.removeProperty('height')
}
function updateHomeDragGhost(event: {
clientX: number
clientY: number
}): void {
const ghost = homeDragGhost
const grip = homeDragGrip
const clipBounds = homeDragClipBounds
const previewBounds = homeDragPreviewBounds
if (!ghost || !grip || !clipBounds || !previewBounds) return
const left = event.clientX - grip.x
const top = event.clientY - grip.y
ghost.style.transform = `translate3d(${left - clipBounds.left}px, ${top - clipBounds.top}px, 0)`
homeDragPreviewBounds = viewportRectAt(
left,
top,
previewBounds.width,
previewBounds.height,
)
}
function createHomeDragGhost(event: PointerEvent): void {
clearHomeDragGhost()
const eventTarget =
event.currentTarget instanceof Element
? event.currentTarget
: event.target instanceof Element
? event.target
: null
const source = eventTarget?.closest<HTMLElement>('.app-icon-item')
const layer = homeDragLayer.value
const springboard = source?.closest<HTMLElement>('.springboard')
const portal = layer?.closest<HTMLElement>('.phone-home-drag-portal')
const geometry = readPhoneViewportGeometry(source ?? null)
if (!source || !layer || !springboard || !portal || !geometry) return
const portalBounds = portal.getBoundingClientRect()
const clipBounds = geometry.rect(springboard)
const sourceBounds = geometry.rect(source)
if (
clipBounds.width <= 0 ||
clipBounds.height <= 0 ||
sourceBounds.width <= 0 ||
sourceBounds.height <= 0
) {
return
}
layer.style.left = `${clipBounds.left - portalBounds.left}px`
layer.style.top = `${clipBounds.top - portalBounds.top}px`
layer.style.width = `${clipBounds.width}px`
layer.style.height = `${clipBounds.height}px`
const ghost = source.cloneNode(true) as HTMLElement
ghost.classList.remove(
'app-icon-item--dragging',
'app-icon-item--editing',
'app-icon-item--drag-source',
'app-icon-item--drop-settling',
'home-folder-item--dragging',
'home-item--folder-hover',
)
ghost.classList.add('home-drag-ghost')
ghost.removeAttribute('style')
ghost
.querySelector<HTMLElement>('.app-icon-button')
?.style.removeProperty('transform')
ghost.setAttribute('aria-hidden', 'true')
ghost.querySelector('.app-icon-remove')?.remove()
for (const element of [ghost, ...Array.from(ghost.querySelectorAll('*'))]) {
element.removeAttribute('id')
for (const attribute of element.getAttributeNames()) {
if (attribute.startsWith('data-home-')) element.removeAttribute(attribute)
}
}
for (const control of ghost.querySelectorAll<HTMLElement>(
'a, button, input, select, textarea, [tabindex]',
)) {
control.tabIndex = -1
}
const position = document.createElement('div')
position.className = 'home-drag-position'
position.style.width = `${sourceBounds.width}px`
position.style.height = `${sourceBounds.height}px`
ghost.style.width = `${source.offsetWidth}px`
ghost.style.height = `${source.offsetHeight}px`
ghost.style.transform = `scale(${geometry.scaleX}, ${geometry.scaleY})`
position.appendChild(ghost)
homeDragGhost = position
homeDragGrip = {
x: event.clientX - sourceBounds.left,
y: event.clientY - sourceBounds.top,
}
homeDragGeometry = geometry
homeDragClipBounds = clipBounds
homeDragPreviewBounds = sourceBounds
layer.appendChild(position)
homeDragVisualActive.value = true
updateHomeDragGhost(event)
}
function startHomeDrag(
area: HomeArea,
index: number,
event: PointerEvent,
): void {
function startHomeDrag(area: HomeArea, index: number): void {
clearTemporaryHomePage()
lastHomePointer = null
draggingHomeApp.value = { area, index }
createHomeDragGhost(event)
}
function clearFolderHover(): void {
@@ -922,40 +754,10 @@ function queueFolderHover(event: PointerEvent): void {
return
}
const targetElement = Array.from(
document.querySelectorAll<HTMLElement>('[data-home-area][data-home-index]'),
)
.filter((element) => {
const area = element.dataset.homeArea as HomeArea | undefined
const index = Number(element.dataset.homeIndex)
if (
(area !== 'grid' && area !== 'dock') ||
!Number.isInteger(index) ||
(area === dragged.area && index === dragged.index)
) {
return false
}
if (
area === 'grid' &&
Number(
element.closest<HTMLElement>('[data-home-page]')?.dataset.homePage,
) !== phone.currentPage
) {
return false
}
const bounds = homeDragViewportRect(element)
return (
event.clientX >= bounds.left &&
event.clientX <= bounds.right &&
event.clientY >= bounds.top &&
event.clientY <= bounds.bottom
)
})
.sort((left, right) => {
const leftIsDock = left.dataset.homeArea === 'dock'
const rightIsDock = right.dataset.homeArea === 'dock'
return Number(rightIsDock) - Number(leftIsDock)
})[0]
const targetElement = document
.elementsFromPoint(event.clientX, event.clientY)
.map((element) => element.closest<HTMLElement>('[data-home-index]'))
.find((element) => element && !element.closest('.app-icon-item--dragging'))
const area = targetElement?.dataset.homeArea as HomeArea | undefined
const targetIndex = Number(targetElement?.dataset.homeIndex)
if (
@@ -1020,7 +822,6 @@ function queueFolderHover(event: PointerEvent): void {
if (folderId) {
draggingHomeApp.value = null
lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage()
openedFolderId.value = folderId
}
@@ -1031,7 +832,6 @@ function queueFolderHover(event: PointerEvent): void {
function moveHomeDrag(event: PointerEvent): void {
if (!draggingHomeApp.value) return
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
updateHomeDragGhost(event)
if (resolveHomeEdgeTurn(event)) {
clearFolderHover()
queueEdgePageTurn(event, 'app')
@@ -1045,7 +845,7 @@ async function animateHomeItemDrop(
item: HomeItem,
area: HomeArea,
index: number,
from: PhoneViewportRect,
from: DOMRect,
): Promise<void> {
await nextTick()
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
@@ -1061,10 +861,7 @@ async function animateHomeItemDrop(
)
if (!appElement) return
const target = homeDragViewportRect(
appElement,
readPhoneViewportGeometry(appElement),
)
const target = appElement.getBoundingClientRect()
const { x: offsetX, y: offsetY } = springboardViewportDeltaToLocal(
from.left - target.left,
from.top - target.top,
@@ -1127,16 +924,13 @@ function nearestGridDropTarget(event: {
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
)
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
const springboard = grid?.closest<HTMLElement>('.springboard')
if (!grid || !pageElement || !springboard) return null
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(springboard)
const pageBounds = homeDragViewportRect(pageElement, geometry)
const springboardBounds = homeDragViewportRect(springboard, geometry)
if (!grid || !pageElement) return null
const pageBounds = pageElement.getBoundingClientRect()
if (
event.clientX < springboardBounds.left ||
event.clientX > springboardBounds.right ||
event.clientY < springboardBounds.top ||
event.clientY > springboardBounds.bottom
event.clientX < pageBounds.left ||
event.clientX > pageBounds.right ||
event.clientY < pageBounds.top ||
event.clientY > pageBounds.bottom
) {
return null
}
@@ -1146,15 +940,7 @@ function nearestGridDropTarget(event: {
const closestIndex = nearestSpringboardRectIndex(
event.clientX,
event.clientY,
slots.map((slot) => {
const bounds = homeDragViewportRect(slot, geometry)
return {
height: bounds.height,
left: springboardBounds.left + (bounds.left - pageBounds.left),
top: springboardBounds.top + (bounds.top - pageBounds.top),
width: bounds.width,
}
}),
slots.map((slot) => slot.getBoundingClientRect()),
)
const closest = closestIndex === null ? null : slots[closestIndex]
const targetOffset = Number(closest?.dataset.homeTargetOffset)
@@ -1167,8 +953,7 @@ function nearestDockDropTarget(event: {
}): HTMLElement | null {
const dock = document.querySelector<HTMLElement>('.app-dock')
if (!dock) return null
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(dock)
const dockBounds = homeDragViewportRect(dock, geometry)
const dockBounds = dock.getBoundingClientRect()
if (
event.clientX < dockBounds.left ||
event.clientX > dockBounds.right ||
@@ -1185,7 +970,7 @@ function nearestDockDropTarget(event: {
const closestIndex = nearestSpringboardRectIndex(
event.clientX,
event.clientY,
slots.map((slot) => homeDragViewportRect(slot, geometry)),
slots.map((slot) => slot.getBoundingClientRect()),
)
return closestIndex === null ? null : slots[closestIndex]
}
@@ -1194,14 +979,12 @@ function finishHomeDrag(event: PointerEvent): void {
clearEdgePageTurn()
clearFolderHover()
const dragged = draggingHomeApp.value
if (!dragged) {
clearHomeDragGhost()
return
}
updateHomeDragGhost(event)
if (!dragged) return
const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
const dropGhost = homeDragGhost
const dropOrigin = homeDragPreviewBounds ? { ...homeDragPreviewBounds } : null
const draggedElement = document.querySelector<HTMLElement>(
`[data-home-area="${dragged.area}"][data-home-index="${dragged.index}"]`,
)
const dropOrigin = draggedElement?.getBoundingClientRect()
const dockTarget = nearestDockDropTarget(event)
const gridTarget = dockTarget ? null : nearestGridDropTarget(event)
let dropArea = dragged.area
@@ -1239,14 +1022,7 @@ function finishHomeDrag(event: PointerEvent): void {
lastHomePointer = null
clearTemporaryHomePage(moved && gridTarget !== null)
if (moved && draggedItem && dropOrigin) {
void animateHomeItemDrop(
draggedItem,
dropArea,
dropIndex,
dropOrigin,
).finally(() => clearHomeDragGhost(dropGhost))
} else {
clearHomeDragGhost()
void animateHomeItemDrop(draggedItem, dropArea, dropIndex, dropOrigin)
}
}
@@ -1255,7 +1031,6 @@ function stopHomeDrag(): void {
clearFolderHover()
draggingHomeApp.value = null
lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage()
}
@@ -1268,9 +1043,7 @@ function reorderHomeApp(
const appElement = document.querySelector<HTMLElement>(
`[data-home-area="${area}"][data-home-index="${sourceIndex}"]`,
)
const moveOrigin = appElement
? homeDragViewportRect(appElement, readPhoneViewportGeometry(appElement))
: null
const moveOrigin = appElement?.getBoundingClientRect()
const targetIndex = homeKeyboardTarget(
appStore.homeLayout,
area,
@@ -1358,10 +1131,7 @@ function extractOpenedFolderApp(
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
)
?.closest<HTMLElement>('.app-icon-item')
const geometry = readPhoneViewportGeometry(sourceElement ?? null)
const sourceBounds = sourceElement
? homeDragViewportRect(sourceElement, geometry)
: null
const sourceBounds = sourceElement?.getBoundingClientRect()
const candidates = Array.from(
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
).filter((element) => {
@@ -1375,8 +1145,8 @@ function extractOpenedFolderApp(
})
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
if (!closest) return candidate
const bounds = homeDragViewportRect(candidate, geometry)
const closestBounds = homeDragViewportRect(closest, geometry)
const bounds = candidate.getBoundingClientRect()
const closestBounds = closest.getBoundingClientRect()
const distance = Math.hypot(
event.clientX - (bounds.left + bounds.width / 2),
event.clientY - (bounds.top + bounds.height / 2),
@@ -1442,7 +1212,6 @@ onBeforeUnmount(() => {
pendingWidgetPointer = null
lastWidgetPointer = null
lastHomePointer = null
clearHomeDragGhost()
temporaryHomePage.value = null
draggingHomeApp.value = null
draggingWidgetId.value = null
@@ -1458,6 +1227,7 @@ onBeforeUnmount(() => {
'springboard--dragging': dragging,
'springboard--editing': editMode,
'springboard--folder-open': folderOverlayVisible,
'springboard--home-dragging': draggingHomeApp !== null,
'springboard--widget-dragging': draggingWidgetId !== null,
},
]"
@@ -1533,7 +1303,6 @@ onBeforeUnmount(() => {
:data-home-index="cell.sourceIndex"
:data-home-target-offset="cell.targetOffset"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:class="{
'home-item--folder-hover':
folderHoverTarget === `grid:${cell.sourceIndex}`,
@@ -1541,7 +1310,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
@edit="enterEditMode"
@remove="removeHomeApp(cell.app.id)"
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
@@ -1556,7 +1325,6 @@ onBeforeUnmount(() => {
:data-home-index="cell.sourceIndex"
:data-home-target-offset="cell.targetOffset"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:folder="cell.folder"
:class="{
'home-item--folder-hover':
@@ -1565,7 +1333,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
@edit="enterEditMode"
@open="openFolder(cell.folder.id)"
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
@@ -1716,10 +1484,6 @@ onBeforeUnmount(() => {
</section>
</div>
<Teleport defer to="#phone-home-drag-portal">
<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>
</Teleport>
<Transition name="edit-done">
<k-glass
v-if="editMode && isEditablePage && !folderOverlayVisible"
@@ -1766,7 +1530,6 @@ onBeforeUnmount(() => {
:data-home-item-key="`app-${slot.app.id}`"
:data-home-index="slot.index"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:show-label="false"
:class="{
'home-item--folder-hover':
@@ -1775,7 +1538,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('dock', slot.index, $event)"
@dragstart="startHomeDrag('dock', slot.index)"
@edit="enterEditMode"
@remove="removeHomeApp(slot.app.id)"
@reorder="reorderHomeApp('dock', slot.index, $event)"
@@ -1789,7 +1552,6 @@ onBeforeUnmount(() => {
:data-home-item-key="slot.folder.id"
:data-home-index="slot.index"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:folder="slot.folder"
:show-label="false"
:class="{
@@ -1799,7 +1561,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('dock', slot.index, $event)"
@dragstart="startHomeDrag('dock', slot.index)"
@edit="enterEditMode"
@open="openFolder(slot.folder.id)"
@reorder="reorderHomeApp('dock', slot.index, $event)"
+1 -2
View File
@@ -119,7 +119,7 @@ const downloadDateDescription = computed(() =>
)
const catalog = computed(() =>
PHONE_APPS.filter((app): app is LaunchablePhoneAppDefinition => {
if (!isLaunchablePhoneApp(app) || app.id === 'app-store' || app.adminOnly) {
if (!isLaunchablePhoneApp(app) || app.id === 'app-store') {
return false
}
@@ -140,7 +140,6 @@ const dailyCandidates = computed(() =>
PHONE_APPS.filter(
(app): app is LaunchablePhoneAppDefinition =>
isLaunchablePhoneApp(app) &&
!app.adminOnly &&
!isExternalPhoneApp(app) &&
app.id !== 'app-store' &&
!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
+1 -1
View File
@@ -791,7 +791,7 @@ onBeforeUnmount(() => {
flex-direction: column;
color: #f6f7f9;
background: #07090c;
font-family: var(--sky-font-family);
font-family: Inter, system-ui, sans-serif;
}
.billing-app--light {
--billing-border: rgb(15 23 42 / 10%);
+7 -1
View File
@@ -1156,7 +1156,13 @@ onMounted(async () => {
padding: 47px 0 24px;
background: var(--bg);
color: var(--label);
font-family: var(--sky-font-family);
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'SF Pro Display',
system-ui,
sans-serif;
}
.calendar--light {
+1 -1
View File
@@ -2195,7 +2195,7 @@ onMounted(async () => {
overflow: hidden;
background: #151613;
color: #f8f8f4;
font-family: var(--sky-font-family);
font-family: Inter, system-ui, sans-serif;
}
.citymarkt--light {
--ink: #fff;
+1 -1
View File
@@ -3586,7 +3586,7 @@ onMounted(async () => {
overflow: hidden;
background: #12171b !important;
color: #f7f8f4;
font-family: var(--sky-font-family);
font-family: Inter, system-ui, sans-serif;
}
.feather-app--active.feather-app--light {
--feather-panel: #f0f1ec;
+4 -18
View File
@@ -77,10 +77,6 @@ import {
SkyToggle,
} from '@/ui'
import { nuiCall } from '@/utils/nui'
import {
getPhoneOutputVolume,
subscribePhoneOutputVolume,
} from '@/utils/phoneAudio'
type Tab = 'feed' | 'discover' | 'create' | 'activity' | 'profile'
type AuthMode = 'login' | 'register'
@@ -198,8 +194,6 @@ let flipTokYoutubePlayer: YouTubePlayer | null = null
let flipTokYoutubeApi: YouTubeApi | null = null
let flipTokYoutubeOwner = ''
let flipTokYoutubeVideoId = ''
let flipTokYoutubeVolume = 0
let removePhoneOutputVolumeListener: (() => void) | undefined
let observer: IntersectionObserver | null = null
let videoClickTimer: number | null = null
let likePulseTimer: number | null = null
@@ -792,7 +786,6 @@ async function playFlipTokYoutube(
seconds = 0,
): Promise<void> {
flipTokYoutubeOwner = owner
flipTokYoutubeVolume = volume
try {
const api = await loadYouTubeApi()
flipTokYoutubeApi = api
@@ -801,7 +794,7 @@ async function playFlipTokYoutube(
flipTokYoutubeVideoId = videoId
flipTokYoutubePlayer.loadVideoById(videoId)
}
flipTokYoutubePlayer.setVolume(volume * getPhoneOutputVolume())
flipTokYoutubePlayer.setVolume(volume)
flipTokYoutubePlayer.seekTo(Math.max(0, seconds), true)
flipTokYoutubePlayer.playVideo()
if (owner === 'composer') customMusicLoadFailed.value = false
@@ -842,7 +835,7 @@ async function playFlipTokYoutube(
},
onReady: (event) => {
flipTokYoutubePlayer = event.target
event.target.setVolume(volume * getPhoneOutputVolume())
event.target.setVolume(volume)
event.target.seekTo(Math.max(0, seconds), true)
event.target.playVideo()
if (owner === 'composer') customMusicLoadFailed.value = false
@@ -1634,10 +1627,7 @@ watch(originalVolume, (value) => {
watch(musicVolume, (value) => {
if (composerMusic.value) composerMusic.value.volume = value / 100
if (flipTokYoutubeOwner === 'composer') {
flipTokYoutubeVolume = value
flipTokYoutubePlayer?.setVolume(value * getPhoneOutputVolume())
}
if (flipTokYoutubeOwner === 'composer') flipTokYoutubePlayer?.setVolume(value)
})
watch(
@@ -1665,9 +1655,6 @@ watch(
)
onMounted(async () => {
removePhoneOutputVolumeListener = subscribePhoneOutputVolume((volume) => {
flipTokYoutubePlayer?.setVolume(flipTokYoutubeVolume * volume)
})
const profileSelection = messageMedia.consumeMany<ProfileMediaContext>(
'fliptok:profile-avatar',
)
@@ -1744,7 +1731,6 @@ onMounted(async () => {
})
onBeforeUnmount(() => {
removePhoneOutputVolumeListener?.()
observer?.disconnect()
if (videoClickTimer !== null) window.clearTimeout(videoClickTimer)
if (likePulseTimer !== null) window.clearTimeout(likePulseTimer)
@@ -3395,7 +3381,7 @@ onBeforeUnmount(() => {
overflow: hidden;
background: #000;
color: #fff;
font-family: var(--sky-font-family);
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
}
.state,
.light-empty,
+1 -1
View File
@@ -1526,7 +1526,7 @@ onMounted(async () => {
overflow: hidden;
background: #12171b;
color: #f7f7f2;
font-family: var(--sky-font-family);
font-family: Inter, system-ui, sans-serif;
}
.pages--light {
--yellow: #8a6500;
+1 -1
View File
@@ -279,7 +279,7 @@ onBeforeUnmount(() => {
background:
radial-gradient(circle at 88% 8%, rgb(255 255 255 / 72%), transparent 28%),
linear-gradient(155deg, #f4efff 0%, #e7ddff 55%, #d9cdf7 100%);
font-family: var(--sky-font-family);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
user-select: none;
}
+1 -1
View File
@@ -420,7 +420,7 @@ onBeforeUnmount(() => {
background:
radial-gradient(circle at 88% 7%, rgb(108 231 218 / 32%), transparent 30%),
linear-gradient(155deg, #effcf7 0%, #cfeee5 52%, #abdcd7 100%);
font-family: var(--sky-font-family);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
touch-action: manipulation;
user-select: none;
}
+1 -1
View File
@@ -386,7 +386,7 @@ onBeforeUnmount(() => {
padding: 52px 14px 25px;
color: #f5fbff;
background: radial-gradient(circle at 50% 0, #253163, #090c22 58%, #050715);
font-family: var(--sky-font-family);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
user-select: none;
touch-action: manipulation;
}
+1 -1
View File
@@ -335,7 +335,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
background:
radial-gradient(circle at 86% 8%, rgb(255 192 94 / 28%), transparent 29%),
linear-gradient(155deg, #fff3dc 0%, #f3d8b7 55%, #e8b98d 100%);
font-family: var(--sky-font-family);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
touch-action: none;
user-select: none;
}
+1 -1
View File
@@ -167,7 +167,7 @@ onBeforeUnmount(() => {
</template>
<style scoped>
.flappy-app { --sky-a:#50d8f2;--sky-b:#765ce8;--tower:#574be8;--tower-light:#9b94ff;--tower-dark:#3429a6;--tower-glow:#79e7ff; position:absolute;inset:0;overflow:hidden;padding:52px 16px 27px;color:#fff;background:linear-gradient(160deg,#19375e,#433b80);font-family:var(--sky-font-family);user-select:none;touch-action:manipulation; }
.flappy-app { --sky-a:#50d8f2;--sky-b:#765ce8;--tower:#574be8;--tower-light:#9b94ff;--tower-dark:#3429a6;--tower-glow:#79e7ff; position:absolute;inset:0;overflow:hidden;padding:52px 16px 27px;color:#fff;background:linear-gradient(160deg,#19375e,#433b80);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;user-select:none;touch-action:manipulation; }
.flappy-app--playing { padding:0; }
.flappy-app--neon { --sky-a:#151c58;--sky-b:#a329a2;--tower:#19dfe6;--tower-light:#82ffff;--tower-dark:#087f91;--tower-glow:#26fbff; }.flappy-app--storm { --sky-a:#6f8497;--sky-b:#2b3955;--tower:#df765f;--tower-light:#ffb18e;--tower-dark:#8d3e39;--tower-glow:#ff997d; }
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#d9e9fa;font-size:14px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:1px 0 0;font-size:32px;line-height:1}.flappy-header button:not(.sky-button--glass),.flappy-toolbar button:not(.sky-button--glass){width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff35;border-radius:12px;color:#fff;background:#ffffff18}.flappy-header .sky-button--glass,.flappy-toolbar .sky-button--glass{color:#fff}
+1 -1
View File
@@ -306,7 +306,7 @@ onBeforeUnmount(() => {
background:
radial-gradient(circle at 85% 8%, rgb(100 211 91 / 22%), transparent 30%),
linear-gradient(165deg, #142b25 0%, #0c1715 62%, #08100f 100%);
font-family: var(--sky-font-family);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
user-select: none;
touch-action: none;
}
+1 -1
View File
@@ -321,7 +321,7 @@ onBeforeUnmount(() => {
</template>
<style scoped>
.tower-app { position: absolute; inset: 0; overflow: hidden; padding: 52px 16px 27px; color: #eef5ff; background: radial-gradient(circle at 75% 8%, #7146c866, transparent 35%), linear-gradient(170deg, #161634, #242054 52%, #10132c); font-family: var(--sky-font-family); user-select: none; touch-action: manipulation; }
.tower-app { position: absolute; inset: 0; overflow: hidden; padding: 52px 16px 27px; color: #eef5ff; background: radial-gradient(circle at 75% 8%, #7146c866, transparent 35%), linear-gradient(170deg, #161634, #242054 52%, #10132c); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; user-select: none; touch-action: manipulation; }
.tower-app--playing { padding: 0; }
.tower-header { height: 50px; display: flex; align-items: center; justify-content: space-between; }
.tower-header span { display: block; color: #c1b8f1; font-size: 10px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
@@ -1,70 +0,0 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const gallerySource = readFileSync(
new URL('./PhoneDynamicIslandGallery.vue', import.meta.url),
'utf8',
)
const islandSource = readFileSync(
new URL('../../components/PhoneDynamicIsland.vue', import.meta.url),
'utf8',
)
const appSource = readFileSync(
new URL('../../App.vue', import.meta.url),
'utf8',
)
const routerSource = readFileSync(
new URL('../../router/index.ts', import.meta.url),
'utf8',
)
describe('Dynamic Island development gallery contract', () => {
it('shows every runtime activity and both supported presentation sizes', () => {
for (const activity of [
'incoming-call',
'call',
'music',
'recording',
'timer',
'stopwatch',
]) {
expect(gallerySource).toContain(`activity: '${activity}'`)
}
expect(gallerySource).toContain("label: 'Compact'")
expect(gallerySource).toContain("label: 'Expanded'")
expect(
gallerySource.match(
/activity: '(?:incoming-call|call|music|recording|timer|stopwatch)'/g,
),
).toHaveLength(11)
})
it('uses the production component with inert preview data', () => {
expect(gallerySource).toContain('<PhoneDynamicIsland')
expect(gallerySource).toContain(':preview-activity="variant.activity"')
expect(islandSource).toContain('previewActivity?: DynamicIslandActivity')
expect(islandSource).toContain(
':data-preview="props.preview ? \'true\' : undefined"',
)
expect(islandSource).toContain(".phone-dynamic-island[data-preview='true']")
expect(gallerySource).toMatch(
/\.dynamic-island-gallery__stage\s*\{[^}]*width:\s*100%;/s,
)
expect(gallerySource).toMatch(
/dynamic-island-gallery__preview\.phone-dynamic-island[\s\S]*?max-width:\s*calc\(100% - 12px\);/,
)
})
it('keeps the gallery development-only and hides the live island above it', () => {
expect(routerSource).toContain("name: 'development-dynamic-islands'")
expect(routerSource).toContain("path: '/development/dynamic-islands'")
expect(routerSource).toMatch(
/const developmentRoutes[^=]*=\s*import\.meta\.env\.DEV/,
)
expect(appSource).toContain("route.name === 'development-dynamic-islands'")
expect(appSource).toContain(
'v-if="!setupRequired && !isDynamicIslandGalleryRoute"',
)
})
})
@@ -1,303 +0,0 @@
<script setup lang="ts">
import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'
import type { DynamicIslandActivity } from '@/types/dynamicIsland'
type GalleryVariant = {
activity: DynamicIslandActivity
expanded: boolean
eyebrow: string
label: string
progress?: number
subtitle: string
title: string
value: string
}
type GalleryGroup = {
label: string
variants: GalleryVariant[]
}
const galleryGroups: GalleryGroup[] = [
{
label: 'Incoming call',
variants: [
{
activity: 'incoming-call',
expanded: true,
eyebrow: 'Incoming call',
label: 'Expanded',
subtitle: 'mobile',
title: 'Tania Castillo',
value: 'Incoming',
},
],
},
{
label: 'Active call',
variants: [
{
activity: 'call',
expanded: false,
eyebrow: 'Connected',
label: 'Compact',
subtitle: '00:42',
title: 'Alex Rivera',
value: '00:42',
},
{
activity: 'call',
expanded: true,
eyebrow: 'Connected',
label: 'Expanded',
subtitle: '00:42',
title: 'Alex Rivera',
value: '00:42',
},
],
},
{
label: 'Music',
variants: [
{
activity: 'music',
expanded: false,
eyebrow: 'Now Playing',
label: 'Compact',
progress: 0.64,
subtitle: 'Los Santos Radio',
title: 'Night Drive',
value: 'Night Drive',
},
{
activity: 'music',
expanded: true,
eyebrow: 'Now Playing',
label: 'Expanded',
progress: 0.64,
subtitle: 'Los Santos Radio',
title: 'Night Drive',
value: 'Night Drive',
},
],
},
{
label: 'Voice recording',
variants: [
{
activity: 'recording',
expanded: false,
eyebrow: 'Recording',
label: 'Compact',
subtitle: 'Voice memo',
title: '00:37',
value: '00:37',
},
{
activity: 'recording',
expanded: true,
eyebrow: 'Recording',
label: 'Expanded',
subtitle: 'Voice memo',
title: '00:37',
value: '00:37',
},
],
},
{
label: 'Timer',
variants: [
{
activity: 'timer',
expanded: false,
eyebrow: 'Timer',
label: 'Compact',
progress: 0.47,
subtitle: '',
title: '0:21',
value: '0:21',
},
{
activity: 'timer',
expanded: true,
eyebrow: 'Timer',
label: 'Expanded',
progress: 0.47,
subtitle: '',
title: '0:21',
value: '0:21',
},
],
},
{
label: 'Stopwatch',
variants: [
{
activity: 'stopwatch',
expanded: false,
eyebrow: 'Stopwatch',
label: 'Compact',
subtitle: '',
title: '00:11,48',
value: '00:11,48',
},
{
activity: 'stopwatch',
expanded: true,
eyebrow: 'Stopwatch',
label: 'Expanded',
subtitle: '',
title: '00:11,48',
value: '00:11,48',
},
],
},
]
</script>
<template>
<main class="dynamic-island-gallery">
<header class="dynamic-island-gallery__header">
<span>Development preview</span>
<h1>Dynamic Islands</h1>
<p>Every runtime activity in its supported compact and expanded state.</p>
</header>
<section
v-for="group in galleryGroups"
:key="group.label"
class="dynamic-island-gallery__group"
>
<h2>{{ group.label }}</h2>
<article
v-for="variant in group.variants"
:key="`${variant.activity}-${variant.label}`"
class="dynamic-island-gallery__variant"
>
<span>{{ variant.label }}</span>
<div
class="dynamic-island-gallery__stage"
:class="{
'dynamic-island-gallery__stage--expanded': variant.expanded,
}"
>
<PhoneDynamicIsland
class="dynamic-island-gallery__preview"
preview
:preview-activity="variant.activity"
:preview-expanded="variant.expanded"
:preview-eyebrow="variant.eyebrow"
:preview-progress="variant.progress"
:preview-subtitle="variant.subtitle"
:preview-title="variant.title"
:preview-value="variant.value"
/>
</div>
</article>
</section>
</main>
</template>
<style scoped>
.dynamic-island-gallery {
position: absolute;
z-index: 2;
inset: 0;
overflow-y: auto;
padding: 58px 6px 32px;
color: #f7f7fb;
background:
radial-gradient(circle at 50% 0%, rgb(96 75 180 / 22%), transparent 30%),
linear-gradient(180deg, #111116 0%, #070709 100%);
scrollbar-width: none;
}
.dynamic-island-gallery::-webkit-scrollbar {
display: none;
}
.dynamic-island-gallery__header,
.dynamic-island-gallery__group {
width: 100%;
max-width: 356px;
margin-inline: auto;
}
.dynamic-island-gallery__header {
padding: 4px 8px 18px;
}
.dynamic-island-gallery__header span,
.dynamic-island-gallery__variant > span {
color: #a7a7b3;
font-size: 11px;
font-weight: 650;
letter-spacing: 0.55px;
text-transform: uppercase;
}
.dynamic-island-gallery__header h1 {
margin: 4px 0 5px;
font-size: 28px;
letter-spacing: -0.8px;
}
.dynamic-island-gallery__header p {
margin: 0;
color: #b6b6c2;
font-size: 12px;
line-height: 17px;
}
.dynamic-island-gallery__group {
padding: 14px 0 18px;
border-top: 1px solid rgb(255 255 255 / 9%);
}
.dynamic-island-gallery__group h2 {
margin: 0 8px 10px;
font-size: 15px;
letter-spacing: -0.2px;
}
.dynamic-island-gallery__variant + .dynamic-island-gallery__variant {
margin-top: 10px;
}
.dynamic-island-gallery__variant > span {
display: block;
margin: 0 10px 5px;
font-size: 9px;
}
.dynamic-island-gallery__stage {
display: grid;
width: 100%;
min-height: 72px;
overflow: hidden;
place-items: center;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 30px;
background:
radial-gradient(circle at 70% 15%, rgb(93 89 255 / 24%), transparent 34%),
#1a1a22;
}
.dynamic-island-gallery__stage--expanded {
min-height: 142px;
}
:deep(.dynamic-island-gallery__preview.phone-dynamic-island) {
position: relative;
top: auto;
left: auto;
max-width: calc(100% - 12px);
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.dynamic-island-gallery {
scroll-behavior: auto;
}
}
</style>
@@ -229,7 +229,9 @@ describe('development Sky UI Kitchen Sink contract', () => {
/const developmentRoutes[^=]*=\s*import\.meta\.env\.DEV[\s\S]*?import\('@\/views\/development\/SkyUiKitchenSinkView\.vue'\)/,
)
expect(routerSource).toContain("name: 'development-sky-ui'")
expect(appSource).toContain("route.name === 'development-sky-ui'")
expect(appSource).toContain(
"isDevelopment && route.name === 'development-sky-ui'",
)
expect(appSource).toContain(
'isDevelopmentRoute ? String(route.name) : route.path',
)
+3 -218
View File
@@ -23,7 +23,6 @@ const lifecycleEndpoints = new Set([
'notification:focus',
'sim:picker-close',
'ui:input-focus',
'ui:live-activity',
'ui:opened',
'ui:ready',
])
@@ -3098,7 +3097,9 @@ const deviceData = {
ringtoneVolume: 80,
streamerMode: false,
wallpaper: 'custom',
wallpaperHistory: [{ imageUrl: demoWallpaperUrl, wallpaper: 'custom' }],
wallpaperHistory: [
{ imageUrl: demoWallpaperUrl, wallpaper: 'custom' },
],
wallpaperImageUrl: demoWallpaperUrl,
},
version: 1,
@@ -4648,100 +4649,6 @@ function companyWorkContext(testScenario = '') {
}
}
const adminMockApps = {
claimed: ['citymarkt', 'darkchat', 'feather', 'local-pages'],
revision: 3,
uninstalled: ['crypto', 'skyride'],
}
const adminMockDevices = {
1: { account: true, number: '555-0101', security: true },
2: { account: true, number: '555-0102', security: true },
}
function adminMockPlayerDetail(source = 1) {
const primary = source === 1
const deviceState = adminMockDevices[source] ?? adminMockDevices[1]
return {
birthdate: primary ? '1994-04-16' : '1998-11-03',
devices: [
{
account: deviceState.account
? {
email: primary ? 'demo@ifruit.com' : 'jordan@ifruit.com',
id: primary ? 1 : 2,
passwordAvailable: true,
}
: null,
apps: { ...adminMockApps },
createdAt: '2026-08-15 18:42:00',
imei: primary ? '356938035643809' : '356938035643810',
name: primary ? 'Personal iFruit Phone' : 'Service iFruit Phone',
number: deviceState.number,
security: {
enabled: deviceState.security,
failedAttempts: 0,
length: deviceState.security ? 6 : null,
lockedUntil: 0,
},
simRegistered: true,
simType: 'standard',
updatedAt: '2026-08-20 19:04:00',
},
],
firstName: primary ? 'Alex' : 'Jordan',
identifier: primary ? 'char1:demo' : 'char1:jordan',
job: {
grade: primary ? 4 : 1,
gradeLabel: primary ? 'Chief' : 'Officer',
label: 'Los Santos Police Department',
name: 'police',
onDuty: true,
},
lastName: primary ? 'Morgan' : 'Blake',
money: {
bank: primary ? 182450 : 28450,
cash: primary ? 2740 : 950,
currency: '$',
},
name: primary ? 'Alex Morgan' : 'Jordan Blake',
serverName: primary ? 'Skyline' : 'JordanB',
source,
}
}
function adminMockBootstrap() {
return {
audit: [
{
action: 'grant_app',
actorName: 'Skyline',
createdAt: '2026-08-20 19:04:00',
details: { appId: 'darkchat' },
deviceImei: '356938035643810',
id: 1,
targetIdentifier: 'char1:jordan',
targetSource: 2,
},
],
players: [1, 2].map((source) => {
const player = adminMockPlayerDetail(source)
return {
deviceCount: player.devices.length,
grade: player.job.grade,
identifier: player.identifier,
job: player.job.name,
name: player.name,
onDuty: player.job.onDuty,
phoneNumber: player.devices[0]?.number ?? null,
serverName: player.serverName,
source,
}
}),
stats: { accounts: 24, devices: 31, online: 2 },
}
}
app.post('/api/:endpoint', async (request, response, next) => {
const endpoint = request.params.endpoint
const loggedBody = { ...request.body }
@@ -4755,128 +4662,6 @@ app.post('/api/:endpoint', async (request, response, next) => {
response.json({ success: true, data: musicBootstrap() })
return
}
if (endpoint === 'admin:bootstrap') {
response.json({ success: true, data: adminMockBootstrap() })
return
}
if (endpoint === 'admin:player') {
response.json({
success: true,
data: adminMockPlayerDetail(Number(request.body.source) || 1),
})
return
}
if (endpoint === 'admin:save-apps') {
const changes = Array.isArray(request.body.changes)
? request.body.changes
: []
for (const change of changes) {
const appId = String(change.appId ?? '')
const installed = change.installed === true
adminMockApps.claimed = adminMockApps.claimed.filter((id) => id !== appId)
adminMockApps.uninstalled = adminMockApps.uninstalled.filter(
(id) => id !== appId,
)
if (installed) adminMockApps.claimed.push(appId)
else adminMockApps.uninstalled.push(appId)
}
adminMockApps.revision += 1
response.json({
success: true,
data: adminMockPlayerDetail(Number(request.body.source) || 1),
})
return
}
if (endpoint === 'admin:close') {
response.json({ success: true })
return
}
if (endpoint === 'admin:reveal-password') {
response.json({
success: true,
data: { email: 'demo@ifruit.com', password: 'mock-only-password' },
})
return
}
if (endpoint === 'admin:activity') {
if (request.body.kind === 'messages') {
response.json({
success: true,
data: {
kind: 'messages',
entries: [
{
body: 'Meet at Mission Row in ten minutes.',
createdAt: '2026-08-20 19:03:00',
direction: 'outgoing',
id: 'admin-message-1',
messageType: 'text',
otherNumber: '555-0144',
readAt: '2026-08-20 19:03:30',
},
{
body: 'Copy, I am on my way.',
createdAt: '2026-08-20 18:58:00',
direction: 'incoming',
id: 'admin-message-2',
messageType: 'text',
otherNumber: '555-0199',
readAt: null,
},
],
},
})
return
}
response.json({
success: true,
data: {
kind: 'calls',
entries: [
{
answeredAt: '2026-08-20 18:49:05',
direction: 'incoming',
durationSeconds: 184,
endedAt: '2026-08-20 18:52:09',
id: 'admin-call-1',
otherNumber: '555-0177',
startedAt: '2026-08-20 18:49:00',
status: 'completed',
},
{
answeredAt: null,
direction: 'outgoing',
durationSeconds: 0,
endedAt: '2026-08-20 17:13:18',
id: 'admin-call-2',
otherNumber: '555-0112',
startedAt: '2026-08-20 17:13:00',
status: 'missed',
},
],
},
})
return
}
if (endpoint === 'admin:reset-passcode') {
const source = Number(request.body.source) || 1
adminMockDevices[source].security = false
response.json({ success: true, data: adminMockPlayerDetail(source) })
return
}
if (endpoint === 'admin:change-number') {
const source = Number(request.body.source) || 1
adminMockDevices[source].number = String(request.body.phoneNumber ?? '')
response.json({ success: true, data: adminMockPlayerDetail(source) })
return
}
if (endpoint === 'admin:factory-reset') {
const source = Number(request.body.source) || 1
adminMockDevices[source].account = false
adminMockDevices[source].security = false
response.json({ success: true, data: adminMockPlayerDetail(source) })
return
}
if (endpoint === 'music:add-youtube') {
const value = String(request.body.url ?? '')
const customTitle = String(request.body.title ?? '').trim()
-1
View File
@@ -1157,7 +1157,6 @@ async function main() {
'notification:focus',
'sim:picker-close',
'ui:input-focus',
'ui:live-activity',
'ui:opened',
'ui:ready',
]
-91
View File
@@ -1,91 +0,0 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
-12
View File
@@ -61,18 +61,6 @@ Config.Security = {
AttemptsPerMinute = 12,
}
Config.AdminPanel = {
Enabled = true,
Command = "phoneadmin",
AdminGroups = { "admin", "superadmin" },
MaximumPlayers = 128,
ReadRequestsPerMinute = 60,
ActionRequestsPerMinute = 30,
CredentialRevealsPerMinute = 6,
AuditLimit = 40,
ActivityLimit = 40,
}
Config.Sim = {
Enabled = true, -- false: devices receive a persistent random number automatically; hex/esx require false
RegisteredItem = "sky_phone_sim_registered",
-29
View File
@@ -1,13 +1,5 @@
Locales["de"] = {
CommandDescription = "Öffne dein Handy.",
AdminCommand = {
CommandDescription = "Öffne das geschützte Handy-Admin-Panel.",
Errors = {
disabled = "Das Handy-Admin-Panel ist deaktiviert.",
not_authorized = "Du hast keinen Zugriff auf das Handy-Admin-Panel.",
default = "Das Handy-Admin-Panel konnte nicht geöffnet werden.",
},
},
Controls = {
OpenPhone = "Handy öffnen",
},
@@ -201,27 +193,6 @@ Locales["de"] = {
contacts = { name = "Favoriten", description = "Rufe deine Lieblingskontakte an oder schreibe ihnen.", choose = "Lieblingskontakte" },
},
},
AdminPanel = {
name = "Phone Admin", subtitle = "Administration", navigation = "Admin-Navigation", refresh = "Admin-Daten aktualisieren", loading = "Geschützte Daten werden geladen...",
tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", audit = "Audit" },
overview = { eyebrow = "Server", title = "Dashboard", body = "Spieler, Geräte, Apps und Handydaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts", audit = "Audit-Einträge", control = "Navigation", features = "Module", featuresBody = "Öffne ein Verwaltungsmodul.", recent = "Letzte Aktivitäten", playerFeature = "Identität, Finanzen, Job und Dienst", deviceFeature = "IMEI, SIM, Nummer und Aktivität", appFeature = "Handy-Apps installieren oder entfernen", accountFeature = "Accountzugriff und geschützte Zugangsdaten", messageFeature = "Letzte SMS-Aktivitäten prüfen", callFeature = "Letzte Anrufaktivitäten prüfen", moderationFeature = "Zugriff, Nummer oder Gerätedaten zurücksetzen", auditFeature = "Sensible Admin-Aktionen prüfen" },
appearance = { eyebrow = "Darstellung", title = "Akzentfarbe", body = "Ändere den Akzent im gesamten Admin-Arbeitsbereich.", colors = { emerald = "Smaragd", blue = "Blau", violet = "Violett", orange = "Orange", red = "Rot" } },
players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." },
search = { players = "Name, ID, Job oder Nummer suchen", apps = "Apps suchen", clear = "Suche leeren" },
detail = { character = "Charakterprofil", data = "Spielerdatenübersicht", cash = "Bargeld", bank = "Bank", job = "Job", duty = "Dienst", onDuty = "Im Dienst", offDuty = "Außer Dienst", identity = "Identität", playerData = "Spielerdaten", identifier = "Charakter-Identifier", birthdate = "Geburtsdatum", grade = "Job-Rang", unknown = "Unbekannt" },
devices = { eyebrow = "Gerätesteuerung", title = "Handys", body = "Prüfe alle Handys des ausgewählten Spielers.", choose = "Handy auswählen", empty = "Kein Handy gefunden", emptyBody = "Dieser Spieler besitzt aktuell kein verwaltbares Handy.", noNumber = "Keine Telefonnummer", noSim = "Keine SIM", imei = "IMEI", updated = "Letzte Aktivität", apps = "Zugewiesene Apps", account = "Verknüpfter Account" },
credentials = { eyebrow = "Geschützte Daten", title = "Zugangsdaten", email = "iFruit-E-Mail", password = "iFruit-Passwort", reveal = "Anzeigen", copy = "Passwort kopieren", copied = "Passwort kopiert.", noAccount = "Mit diesem Handy ist kein iFruit-Account verknüpft.", passcode = "Gerätecode", passcodeHashed = "{length}-stellige PIN · sicher gehasht und nicht wiederherstellbar", passcodeDisabled = "Kein Gerätecode eingerichtet", revealTitle = "Geschütztes Passwort anzeigen?", revealBody = "Diese Aktion wird serverseitig autorisiert, rate-limitiert und im Admin-Audit protokolliert.", cancel = "Abbrechen", confirmReveal = "Passwort anzeigen" },
apps = { eyebrow = "Fernverwaltung", title = "App-Zugriff", description = "Lege App-Zugriffe für dieses Gerät als Entwurf fest. Erst Speichern übernimmt die Änderungen.", installed = "Installiert", available = "Verfügbar", protected = "System-App", changes = "{count} offene Änderungen" },
activity = { protected = "Geschützte Aktivität", messagesTitle = "Nachrichten", messagesBody = "Letzte SMS-Aktivitäten der ausgewählten SIM.", callsTitle = "Anrufe", callsBody = "Letzte Anrufaktivitäten der ausgewählten SIM.", loading = "Aktivitäten werden geladen...", incoming = "Eingehend", outgoing = "Ausgehend", mediaMessage = "{type}-Nachricht", noMessages = "Keine Nachrichtenaktivität gefunden.", noCalls = "Keine Anrufaktivität gefunden.", status = { completed = "Abgeschlossen", missed = "Verpasst", rejected = "Abgelehnt", busy = "Besetzt", unanswered = "Unbeantwortet", cancelled = "Abgebrochen", failed = "Fehlgeschlagen", ringing = "Klingelt" } },
moderation = {
eyebrow = "Geräteverwaltung", title = "Moderationsaktionen", body = "Jede Aktion wird serverseitig autorisiert, rate-limitiert und protokolliert.", resetPasscode = "Gerätecode zurücksetzen", resetPasscodeBody = "Entfernt die Geräte-PIN und fehlgeschlagene Versuche.", changeNumber = "Nummer ändern", changeNumberBody = "Weist der aktuellen SIM eine neue eindeutige Nummer zu.", factoryReset = "Werksreset", factoryResetBody = "Löscht lokale Gerätedaten und trennt den Account.", saveFirst = "Speichere oder verwirf zuerst offene App-Änderungen.", phoneNumber = "Neue Telefonnummer", phoneNumberPlaceholder = "Vollständige konfigurierte Nummer eingeben", typeToConfirm = "Tippe {word}, um den Werksreset zu bestätigen.", confirmWord = "RESET", cancel = "Abbrechen", ["reset-passcodeSuccess"] = "Gerätecode zurückgesetzt.", ["change-numberSuccess"] = "Telefonnummer geändert.", ["factory-resetSuccess"] = "Werksreset abgeschlossen.",
dialogs = { ["reset-passcodeTitle"] = "Gerätecode zurücksetzen?", ["reset-passcodeBody"] = "Der Spieler kann dieses Handy danach ohne die bisherige PIN entsperren.", ["change-numberTitle"] = "Telefonnummer ändern?", ["change-numberBody"] = "Die neue Nummer muss dem Serverformat entsprechen und eindeutig sein.", ["factory-resetTitle"] = "Werksreset für dieses Handy?", ["factory-resetBody"] = "Lokale Gerätedaten, App-Einstellungen, Sicherheit und der verknüpfte Account werden gelöscht. Das kann nicht rückgängig gemacht werden." },
confirm = { ["reset-passcode"] = "Gerätecode zurücksetzen", ["change-number"] = "Nummer ändern", ["factory-reset"] = "Werksreset" },
},
audit = { eyebrow = "Nachvollziehbarkeit", title = "Audit-Verlauf", body = "Sensible Anzeigen und App-Änderungen werden hier protokolliert.", empty = "Noch keine Admin-Aktionen", emptyBody = "Geschützte Aktionen erscheinen hier, nachdem sie ausgeführt wurden.", by = "{actor} · Ziel-ID {target}", actions = { grant_app = "App installiert", revoke_app = "App entfernt", reveal_account_password = "Passwort angezeigt", view_messages = "Nachrichten angesehen", view_calls = "Anrufe angesehen", reset_passcode = "Gerätecode zurückgesetzt", change_number = "Telefonnummer geändert", factory_reset = "Werksreset ausgeführt" } },
editor = { brand = "SKY PHONE", workspace = "ADMIN", players = "Spielerverzeichnis", audit = "Audit-Protokoll", selectPlayer = "Wähle einen Spieler, um Identität, Geräte, Zugangsdaten und App-Zugriffe zu prüfen.", save = "Änderungen speichern", saveHint = "Offene Änderungen übernehmen", saved = "Änderungen gespeichert.", unsaved = "Ungespeicherte Änderungen", close = "Admin-Panel schließen", refresh = "Live-Daten aktualisieren", online = "LIVE", profile = "PROFIL", financial = "FINANZEN", device = "GERÄT", security = "SICHERHEIT", noAutoSave = "Manuelles Speichern", noAutoSaveBody = "Änderungen bleiben lokal, bis der grüne Haken gedrückt wird.", discardTitle = "Ungespeicherte Änderungen verwerfen?", discardBody = "Deine vorgemerkten App-Änderungen wurden noch nicht gespeichert.", keepEditing = "Weiter bearbeiten", discard = "Änderungen verwerfen", saveFailed = "Einige Änderungen konnten nicht gespeichert werden.", noSelection = "Kein Spieler ausgewählt" },
errors = { not_authorized = "Du hast keinen Zugriff auf das Admin-Panel.", rate_limited = "Zu viele Admin-Anfragen. Bitte warte.", player_unavailable = "Dieser Spieler ist nicht mehr online.", device_not_owned = "Dieses Handy gehört nicht mehr zum ausgewählten Spieler.", invalid_app = "Diese App ist nicht auf dem Server registriert.", app_protected = "Diese System-App kann nicht entfernt werden.", revision_conflict = "Das Handy wurde zwischenzeitlich geändert. Aktualisiere und versuche es erneut.", account_not_found = "Mit diesem Handy ist kein iFruit-Account verknüpft.", invalid_phone_number = "Gib eine Telefonnummer im konfigurierten Serverformat ein.", phone_number_unchanged = "Diese SIM verwendet diese Telefonnummer bereits.", phone_number_taken = "Diese Telefonnummer ist bereits vergeben.", no_sim = "Dieses Handy besitzt keine änderbare SIM.", passcode_not_set = "Für dieses Handy ist kein Gerätecode eingerichtet.", device_not_found = "Dieses Handy existiert nicht mehr.", metadata_unsupported = "Die Inventar-Metadaten des Handys konnten nicht aktualisiert werden.", invalid_request = "Die Admin-Anfrage war ungültig.", request_failed = "Die Admin-Anfrage ist fehlgeschlagen.", default = "Das Admin-Panel ist vorübergehend nicht verfügbar." },
},
Apps = {
health = {
name = "Gesundheit",
-29
View File
@@ -1,13 +1,5 @@
Locales["en"] = {
CommandDescription = "Open your phone.",
AdminCommand = {
CommandDescription = "Open the protected phone admin panel.",
Errors = {
disabled = "The phone admin panel is disabled.",
not_authorized = "You do not have access to the phone admin panel.",
default = "The phone admin panel could not be opened.",
},
},
Controls = {
OpenPhone = "Open phone",
},
@@ -201,27 +193,6 @@ Locales["en"] = {
contacts = { name = "Favorites", description = "Call or message your favorite contacts.", choose = "Favorite Contacts" },
},
},
AdminPanel = {
name = "Phone Admin", subtitle = "Administration", navigation = "Admin navigation", refresh = "Refresh admin data", loading = "Loading protected data...",
tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", audit = "Audit" },
overview = { eyebrow = "Server", title = "Dashboard", body = "Players, devices, apps, and phone data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts", audit = "Audit entries", control = "Navigation", features = "Modules", featuresBody = "Open an administration module.", recent = "Recent activity", playerFeature = "Identity, finances, job, and duty", deviceFeature = "IMEI, SIM, number, and activity", appFeature = "Install or remove phone apps", accountFeature = "Account access and protected credentials", messageFeature = "Review recent SMS activity", callFeature = "Review recent call activity", moderationFeature = "Reset access, number, or device data", auditFeature = "Review sensitive admin actions" },
appearance = { eyebrow = "Appearance", title = "Accent color", body = "Change the accent across the complete admin workspace.", colors = { emerald = "Emerald", blue = "Blue", violet = "Violet", orange = "Orange", red = "Red" } },
players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." },
search = { players = "Search name, ID, job, or number", apps = "Search apps", clear = "Clear search" },
detail = { character = "Character profile", data = "Player data overview", cash = "Cash", bank = "Bank", job = "Job", duty = "Duty", onDuty = "On duty", offDuty = "Off duty", identity = "Identity", playerData = "Player data", identifier = "Character identifier", birthdate = "Birthdate", grade = "Job grade", unknown = "Unknown" },
devices = { eyebrow = "Device control", title = "Phones", body = "Inspect every phone assigned to the selected player.", choose = "Choose phone", empty = "No phone found", emptyBody = "This player currently has no phone device that can be managed.", noNumber = "No phone number", noSim = "No SIM", imei = "IMEI", updated = "Last activity", apps = "Claimed apps", account = "Linked account" },
credentials = { eyebrow = "Protected data", title = "Credentials", email = "iFruit email", password = "iFruit password", reveal = "Reveal", copy = "Copy password", copied = "Password copied.", noAccount = "No iFruit account is linked to this phone.", passcode = "Device passcode", passcodeHashed = "{length}-digit PIN · securely hashed and not recoverable", passcodeDisabled = "No passcode configured", revealTitle = "Reveal protected password?", revealBody = "This action is server-authorized, rate-limited, and written to the admin audit log.", cancel = "Cancel", confirmReveal = "Reveal password" },
apps = { eyebrow = "Remote management", title = "App access", description = "Stage app access for this device. Nothing changes until you save.", installed = "Installed", available = "Available", protected = "System app", changes = "{count} pending changes" },
activity = { protected = "Protected activity", messagesTitle = "Messages", messagesBody = "Recent SMS activity for the selected SIM.", callsTitle = "Calls", callsBody = "Recent call activity for the selected SIM.", loading = "Loading activity...", incoming = "Incoming", outgoing = "Outgoing", mediaMessage = "{type} message", noMessages = "No message activity found.", noCalls = "No call activity found.", status = { completed = "Completed", missed = "Missed", rejected = "Rejected", busy = "Busy", unanswered = "Unanswered", cancelled = "Cancelled", failed = "Failed", ringing = "Ringing" } },
moderation = {
eyebrow = "Device administration", title = "Moderation actions", body = "Every action is server-authorized, rate-limited, and audited.", resetPasscode = "Reset passcode", resetPasscodeBody = "Remove the device PIN and clear failed attempts.", changeNumber = "Change number", changeNumberBody = "Assign a new unique number to the current SIM.", factoryReset = "Factory reset", factoryResetBody = "Clear local device data and disconnect the account.", saveFirst = "Save or discard pending app changes first.", phoneNumber = "New phone number", phoneNumberPlaceholder = "Enter the full configured number", typeToConfirm = "Type {word} to confirm the factory reset.", confirmWord = "RESET", cancel = "Cancel", ["reset-passcodeSuccess"] = "Passcode reset.", ["change-numberSuccess"] = "Phone number changed.", ["factory-resetSuccess"] = "Phone factory reset completed.",
dialogs = { ["reset-passcodeTitle"] = "Reset device passcode?", ["reset-passcodeBody"] = "The player can unlock this phone without the previous PIN afterward.", ["change-numberTitle"] = "Change phone number?", ["change-numberBody"] = "The new number must match the configured server number format and be unique.", ["factory-resetTitle"] = "Factory reset this phone?", ["factory-resetBody"] = "This clears local device data, app settings, security, and the linked account. This cannot be undone." },
confirm = { ["reset-passcode"] = "Reset passcode", ["change-number"] = "Change number", ["factory-reset"] = "Factory reset" },
},
audit = { eyebrow = "Accountability", title = "Audit trail", body = "Sensitive reveals and remote app changes are recorded here.", empty = "No admin actions yet", emptyBody = "Protected actions will appear here after they are performed.", by = "{actor} · target ID {target}", actions = { grant_app = "App installed", revoke_app = "App removed", reveal_account_password = "Password revealed", view_messages = "Messages viewed", view_calls = "Calls viewed", reset_passcode = "Passcode reset", change_number = "Phone number changed", factory_reset = "Phone factory reset" } },
editor = { brand = "SKY PHONE", workspace = "ADMIN", players = "Player directory", audit = "Audit log", selectPlayer = "Select a player to inspect identity, devices, credentials, and app access.", save = "Save changes", saveHint = "Apply pending changes", saved = "Changes saved.", unsaved = "Unsaved changes", close = "Close admin panel", refresh = "Refresh live data", online = "LIVE", profile = "PROFILE", financial = "FINANCIAL", device = "DEVICE", security = "SECURITY", noAutoSave = "Manual save", noAutoSaveBody = "Changes stay local until the green check is pressed.", discardTitle = "Discard unsaved changes?", discardBody = "Your staged app changes have not been saved.", keepEditing = "Keep editing", discard = "Discard changes", saveFailed = "Some changes could not be saved.", noSelection = "No player selected" },
errors = { not_authorized = "You do not have access to the admin panel.", rate_limited = "Too many admin requests. Please wait.", player_unavailable = "That player is no longer online.", device_not_owned = "That phone no longer belongs to the selected player.", invalid_app = "That app is not registered on the server.", app_protected = "This system app cannot be removed.", revision_conflict = "The phone changed in the meantime. Refresh and try again.", account_not_found = "No iFruit account is linked to this phone.", invalid_phone_number = "Enter a phone number in the configured server format.", phone_number_unchanged = "This SIM already uses that phone number.", phone_number_taken = "That phone number is already assigned.", no_sim = "This phone has no SIM that can be changed.", passcode_not_set = "This phone has no passcode configured.", device_not_found = "This phone no longer exists.", metadata_unsupported = "The phone inventory metadata could not be updated.", invalid_request = "The admin request was invalid.", request_failed = "The admin request failed.", default = "The admin panel is temporarily unavailable." },
},
Apps = {
health = {
name = "Health",
-1
View File
@@ -99,7 +99,6 @@ server_scripts {
'source/server/phone.lua',
'source/server/device_directory.lua',
'source/server/db_migrate.lua',
'source/server/admin.lua',
'source/server/lb_phone_migration.lua',
'source/server/custom_app_storage.lua',
'source/server/payphones.lua',
-20
View File
@@ -4,7 +4,6 @@ local blocked_phone_controls = { 24, 140, 141, 142, 257, 263, 264 }
local blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 }
local focused_control_groups = { 0, 1, 2 }
local state = {
admin_panel_open = false,
activity_suspended = false,
allow_movement = Config.Phone.AllowMovement,
call_focus = false,
@@ -43,15 +42,6 @@ function SkyPhoneFocus.ApplyGameInputControls(block_look)
end
function SkyPhoneFocus.Resolve(state)
if state.admin_panel_open then
return {
block_game = true,
cursor = true,
focused = true,
game_input = false,
keep_input = false,
}
end
if state.activity_suspended then
return { block_game = false, cursor = false, focused = false, game_input = false, keep_input = false }
end
@@ -122,15 +112,6 @@ function SkyPhoneFocus.SetPhone(open, cursor_disabled)
SkyPhoneFocus.Reapply()
end
function SkyPhoneFocus.SetAdminPanel(open)
state.admin_panel_open = open == true
if state.admin_panel_open then
state.notification_focus = false
state.text_input_focused = false
end
SkyPhoneFocus.Reapply()
end
function SkyPhoneFocus.SetCall(active)
state.call_focus = active == true
SkyPhoneFocus.Reapply()
@@ -161,7 +142,6 @@ function SkyPhoneFocus.SetExternalGameInput(owner_resource, allow_game_input)
end
function SkyPhoneFocus.Reset()
state.admin_panel_open = false
state.activity_suspended = false
state.call_focus = false
state.camera_active = false
+1 -100
View File
@@ -6,9 +6,6 @@ local open_without_focus = false
local device_payload = nil
local equipped_phone_number = nil
local nui_generation = 0
local live_activity_active = false
local open_home_requested = false
local admin_panel_open = false
local function get_equipped_phone_number()
if not device_payload or not device_payload.device.sim then
@@ -65,26 +62,6 @@ Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true
local locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
local function send_admin_panel_open()
SendNUIMessage({
type = "admin:open",
data = {
lang = locale_name,
locales = locale.Nui,
fallbackLocales = Locales.en.Nui,
},
})
end
local function close_admin_panel()
if not admin_panel_open then
return
end
admin_panel_open = false
SkyPhoneFocus.SetAdminPanel(false)
SendNUIMessage({ type = "admin:close" })
end
local function send_open_message()
if not device_payload then
return
@@ -98,9 +75,7 @@ local function send_open_message()
SendNUIMessage({
type = "app:open",
data = payload,
openHome = open_home_requested,
})
open_home_requested = false
end
local function open_phone()
@@ -194,25 +169,6 @@ RegisterCommand("sky_phone_toggle", function()
Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
end, false)
RegisterCommand("sky_phone_live_activity_open", function()
if not live_activity_active or is_open or open_requested then
return
end
open_home_requested = true
local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
if not result or not result.success then
open_home_requested = false
end
end, false)
RegisterKeyMapping(
"sky_phone_live_activity_open",
locale.Controls.OpenPhone,
"keyboard",
"SPACE"
)
if Config.Phone.Keybind then
if type(Config.Phone.Keybind) ~= "string" or Config.Phone.Keybind == "" then
error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.")
@@ -245,9 +201,6 @@ RegisterNUICallback("ui:ready", function(data, cb)
if open_requested and device_payload then
send_open_message()
end
if admin_panel_open then
send_admin_panel_open()
end
SkyPhoneCalls.ReplayNui()
SkyPhoneSimPicker.ReplayNui()
SkyPhoneFocus.Reapply()
@@ -293,51 +246,12 @@ RegisterNUICallback("ui:opened", function(data, cb)
cb({ success = true })
end)
RegisterNetEvent("sky_phone:admin:launch", function()
if is_open or open_requested then
close_phone()
end
admin_panel_open = true
SkyPhoneFocus.SetAdminPanel(true)
send_admin_panel_open()
end)
RegisterNetEvent("sky_phone:admin:command-error", function(error_code)
local messages = locale.AdminCommand.Errors
Bridge.Framework.Notify(
"iFruit",
messages[error_code] or messages.default,
"error",
5000
)
end)
RegisterNUICallback("admin:close", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
close_admin_panel()
cb({ success = true })
end)
RegisterNUICallback("ui:input-focus", function(data, cb)
if type(data) ~= "table" or type(data.active) ~= "boolean" then
cb({ success = false, error = "invalid_request" })
return
end
SkyPhoneFocus.SetTextInputFocused(
data.active and (is_open or open_requested or admin_panel_open)
)
cb({ success = true })
end)
RegisterNUICallback("ui:live-activity", function(data, cb)
if type(data) ~= "table" or type(data.active) ~= "boolean" then
cb({ success = false, error = "invalid_request" })
return
end
live_activity_active = data.active
SkyPhoneFocus.SetTextInputFocused(data.active and (is_open or open_requested))
cb({ success = true })
end)
@@ -388,8 +302,6 @@ RegisterNetEvent("sky_phone:device:invalidated", function()
close_phone(false)
device_payload = nil
update_equipped_phone_number(nil)
live_activity_active = false
open_home_requested = false
end)
RegisterNetEvent("sky_phone:device:error", function(error_code)
@@ -413,13 +325,6 @@ CreateThread(function()
if Config.TestData.Enabled then
TriggerEvent("chat:addSuggestion", "/" .. Config.TestData.Command, locale.TestData.CommandDescription)
end
if Config.AdminPanel.Enabled then
TriggerEvent(
"chat:addSuggestion",
"/" .. Config.AdminPanel.Command,
locale.AdminCommand.CommandDescription
)
end
end)
AddEventHandler("onResourceStop", function(resource_name)
@@ -430,7 +335,6 @@ AddEventHandler("onResourceStop", function(resource_name)
is_open = false
open_requested = false
open_without_focus = false
admin_panel_open = false
TriggerEvent("sky_phone:animation:reset")
SkyPhoneCalls.Reset()
@@ -443,7 +347,4 @@ AddEventHandler("onResourceStop", function(resource_name)
if Config.TestData.Enabled then
TriggerEvent("chat:removeSuggestion", "/" .. Config.TestData.Command)
end
if Config.AdminPanel.Enabled then
TriggerEvent("chat:removeSuggestion", "/" .. Config.AdminPanel.Command)
end
end)
@@ -1,9 +1,5 @@
local callback_groups = {
account = [[login register logout devices remove-device]],
admin = [[
bootstrap player save-apps reveal-password activity
reset-passcode change-number factory-reset
]],
banking = [[overview transfer]],
billing = [[overview list detail markRead pay dispute]],
calendar = [[list create update delete]],
-864
View File
@@ -1,864 +0,0 @@
Bridge.Database.AfterMigration("sky_phone", function()
local BUILTIN_APPS = {
banking = true,
billing = true,
calculator = true,
calendar = true,
camera = true,
citymarkt = true,
citywarn = true,
clock = true,
companies = true,
crewlink = true,
crypto = true,
darkchat = true,
feather = true,
flare = true,
fliptok = true,
garage = true,
health = true,
house = true,
["app-store"] = true,
["local-pages"] = true,
mail = true,
map = true,
memos = true,
memory = true,
messages = true,
minesweeper = true,
music = true,
["neon-drop"] = true,
notes = true,
["number-merge"] = true,
phone = true,
photos = true,
picstagram = true,
radio = true,
settings = true,
["sky-flappy"] = true,
skyride = true,
snake = true,
["tower-stack"] = true,
weather = true,
["weazel-news"] = true,
}
local DEFAULT_INSTALLED_APPS = {
["app-store"] = true,
calculator = true,
calendar = true,
camera = true,
citywarn = true,
clock = true,
health = true,
mail = true,
map = true,
memos = true,
messages = true,
notes = true,
phone = true,
photos = true,
settings = true,
weather = true,
}
local PROTECTED_APPS = {
["app-store"] = true,
camera = true,
citywarn = true,
health = true,
mail = true,
messages = true,
phone = true,
photos = true,
settings = true,
}
local function affected_rows(result)
if type(result) == "number" then
return result
end
return type(result) == "table" and tonumber(result.affectedRows) or 0
end
local function trim(value)
if type(value) ~= "string" then
return ""
end
return value:match("^%s*(.-)%s*$")
end
local function player_name(source)
local first_name = trim(Bridge.Framework.GetFirstname(source))
local last_name = trim(Bridge.Framework.GetLastname(source))
local character_name = trim((first_name .. " " .. last_name))
return character_name ~= "" and character_name or GetPlayerName(source) or ("Player %s"):format(source)
end
local function require_admin(source, operation, maximum)
if not Config.AdminPanel.Enabled
or not Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)
then
Bridge.Debug("warn", "[sky_phone] Rejected admin panel access from source %s.", tostring(source))
return nil, { success = false, error = "not_authorized" }
end
if not SkyPhone.AllowOperation(source, "admin_" .. operation, maximum, 60) then
return nil, { success = false, error = "rate_limited" }
end
return true
end
if type(Config.AdminPanel.Command) ~= "string" or Config.AdminPanel.Command == "" then
error("[sky_phone] Config.AdminPanel.Command must be a non-empty command name.")
end
RegisterCommand(Config.AdminPanel.Command, function(command_source)
local player_source = tonumber(command_source)
if not player_source or player_source < 1 then
Bridge.Debug("warn", "[sky_phone] The admin panel command can only be used by a player.")
return
end
if not Config.AdminPanel.Enabled then
TriggerClientEvent("sky_phone:admin:command-error", player_source, "disabled")
return
end
if not Bridge.Framework.HasAdminGroup(player_source, Config.AdminPanel.AdminGroups) then
Bridge.Debug(
"warn",
"[sky_phone] Rejected admin panel command from source %s.",
tostring(player_source)
)
TriggerClientEvent("sky_phone:admin:command-error", player_source, "not_authorized")
return
end
TriggerClientEvent("sky_phone:admin:launch", player_source)
end, false)
local function normalize_source(value)
local player_source = tonumber(value)
if not player_source or player_source < 1 or player_source ~= math.floor(player_source) then
return nil
end
if not Bridge.Framework.GetIdentifier(player_source) then
return nil
end
return player_source
end
local function collect_device_imeis(source, identifier)
local imeis = {}
local seen = {}
local function add_imei(imei)
if SkyPhoneImei.IsValid(imei) and not seen[imei] then
seen[imei] = true
imeis[#imeis + 1] = imei
end
end
for _, item in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
add_imei(item.metadata and item.metadata.imei)
end
local rows = Bridge.Database.Query([[
SELECT `device_imei` AS `imei`
FROM `sky_phone_character_devices`
WHERE `owner_identifier` = ?
UNION
SELECT device.`imei`
FROM `sky_phone_devices` device
JOIN `sky_phone_sims` sim ON sim.`id` = device.`sim_id`
WHERE sim.`owner_identifier` = ?
]], { identifier, identifier })
for _, row in ipairs(rows) do
add_imei(row.imei)
end
table.sort(imeis)
return imeis
end
local function normalize_app_ids(value)
local normalized = {}
local seen = {}
if type(value) ~= "table" then
return normalized
end
for index = 1, #value do
local app_id = value[index]
if type(app_id) == "string"
and #app_id > 0
and #app_id <= 64
and not seen[app_id]
then
seen[app_id] = true
normalized[#normalized + 1] = app_id
end
end
return normalized
end
local function load_app_payload(encoded)
if type(encoded) ~= "string" or encoded == "" then
return {}, {}, {}
end
local payload = json.decode(encoded)
if type(payload) ~= "table" then
error("[sky_phone] Stored admin target app payload is not a JSON object.")
end
return payload, normalize_app_ids(payload.claimedApps), normalize_app_ids(payload.uninstalledApps)
end
local function load_player_devices(source, identifier)
local imeis = collect_device_imeis(source, identifier)
if #imeis == 0 then
return {}
end
local placeholders = {}
for index = 1, #imeis do
placeholders[index] = "?"
end
local rows = Bridge.Database.Query(([[
SELECT device.`imei`, device.`device_name`, device.`created_at`, device.`updated_at`,
sim.`phone_number`, sim.`sim_type`, sim.`registered_at`,
account.`id` AS `account_id`, account.`email` AS `account_email`,
security.`passcode_length`, security.`failed_attempts`, security.`locked_until`,
app_data.`payload` AS `apps_payload`, app_data.`revision` AS `apps_revision`
FROM `sky_phone_devices` device
LEFT JOIN `sky_phone_sims` sim ON sim.`id` = device.`sim_id`
LEFT JOIN `sky_phone_accounts` account ON account.`id` = device.`account_id`
LEFT JOIN `sky_phone_device_security` security ON security.`device_imei` = device.`imei`
LEFT JOIN `sky_phone_device_data` app_data
ON app_data.`device_imei` = device.`imei` AND app_data.`namespace` = 'apps'
WHERE device.`imei` IN (%s)
ORDER BY device.`updated_at` DESC, device.`imei` ASC
]]):format(table.concat(placeholders, ", ")), imeis)
local devices = {}
for _, row in ipairs(rows) do
local _, claimed_apps, uninstalled_apps = load_app_payload(row.apps_payload)
devices[#devices + 1] = {
imei = row.imei,
name = row.device_name,
createdAt = row.created_at,
updatedAt = row.updated_at,
number = row.phone_number,
simType = row.sim_type,
simRegistered = row.registered_at ~= nil,
account = row.account_id and {
id = tonumber(row.account_id),
email = row.account_email,
passwordAvailable = true,
} or nil,
security = {
enabled = row.passcode_length ~= nil,
length = row.passcode_length and tonumber(row.passcode_length) or nil,
failedAttempts = tonumber(row.failed_attempts) or 0,
lockedUntil = tonumber(row.locked_until) or 0,
},
apps = {
claimed = claimed_apps,
uninstalled = uninstalled_apps,
revision = tonumber(row.apps_revision) or 0,
},
}
end
return devices
end
local function build_player_summary(source)
local identifier = Bridge.Framework.GetIdentifier(source)
local devices = load_player_devices(source, identifier)
local job = Bridge.Framework.GetJob(source)
return {
source = source,
identifier = identifier,
name = player_name(source),
serverName = GetPlayerName(source) or "",
job = job.label ~= "" and job.label or job.name,
grade = job.grade,
onDuty = job.onDuty,
deviceCount = #devices,
phoneNumber = devices[1] and devices[1].number or nil,
}
end
local function list_players()
local sources = Bridge.Framework.GetPlayers()
table.sort(sources, function(left, right)
return tonumber(left) < tonumber(right)
end)
local players = {}
local maximum = math.max(1, math.floor(tonumber(Config.AdminPanel.MaximumPlayers) or 128))
for index = 1, math.min(#sources, maximum) do
local player_source = tonumber(sources[index])
if player_source and Bridge.Framework.GetIdentifier(player_source) then
players[#players + 1] = build_player_summary(player_source)
end
end
return players
end
local function load_player_detail(source)
local identifier = Bridge.Framework.GetIdentifier(source)
local job = Bridge.Framework.GetJob(source)
return {
source = source,
identifier = identifier,
name = player_name(source),
serverName = GetPlayerName(source) or "",
firstName = trim(Bridge.Framework.GetFirstname(source)),
lastName = trim(Bridge.Framework.GetLastname(source)),
birthdate = trim(Bridge.Framework.GetBirthdate(source)),
job = {
name = job.name,
label = job.label,
grade = job.grade,
gradeLabel = job.gradeLabel,
onDuty = job.onDuty,
},
money = {
bank = tonumber(Bridge.Framework.GetMoney(source, "bank")) or 0,
cash = tonumber(Bridge.Framework.GetMoney(source, "cash")) or 0,
currency = Config.Banking.Currency,
},
devices = load_player_devices(source, identifier),
}
end
local function find_owned_device(source, imei)
local identifier = Bridge.Framework.GetIdentifier(source)
for _, device in ipairs(load_player_devices(source, identifier)) do
if device.imei == imei then
return device, identifier
end
end
return nil, identifier
end
local function load_device_sim(imei)
local rows = Bridge.Database.Query([[
SELECT sim.`id`, sim.`phone_number`
FROM `sky_phone_devices` device
JOIN `sky_phone_sims` sim ON sim.`id` = device.`sim_id`
WHERE device.`imei` = ?
LIMIT 1
]], { imei })
return rows[1]
end
local function app_metadata(app_id)
if BUILTIN_APPS[app_id] then
return {
defaultInstalled = DEFAULT_INSTALLED_APPS[app_id] == true,
removable = PROTECTED_APPS[app_id] ~= true,
}
end
if SkyPhoneApps.GetPolicy(app_id) then
return { defaultInstalled = false, removable = true }
end
return nil
end
local function remove_app_id(values, app_id)
local next_values = {}
for index = 1, #values do
if values[index] ~= app_id then
next_values[#next_values + 1] = values[index]
end
end
return next_values
end
local function add_app_id(values, app_id)
for index = 1, #values do
if values[index] == app_id then
return values
end
end
values[#values + 1] = app_id
return values
end
local function write_audit(actor_source, target_source, target_identifier, imei, action, details)
local actor_identifier = Bridge.Framework.GetIdentifier(actor_source)
local result = Bridge.Database.Query([[
INSERT INTO `sky_phone_admin_audit`
(`actor_identifier`, `actor_name`, `target_identifier`, `target_source`, `device_imei`, `action`, `details`)
VALUES (?, ?, ?, ?, ?, ?, ?)
]], {
actor_identifier,
player_name(actor_source),
target_identifier,
target_source,
imei,
action,
json.encode(details or {}),
})
if affected_rows(result) ~= 1 then
error("[sky_phone] Admin audit insert did not affect exactly one row.")
end
end
local function load_audit()
local limit = math.max(1, math.min(100, math.floor(tonumber(Config.AdminPanel.AuditLimit) or 40)))
local rows = Bridge.Database.Query(([[
SELECT `id`, `actor_name`, `target_identifier`, `target_source`, `device_imei`,
`action`, `details`, `created_at`
FROM `sky_phone_admin_audit`
ORDER BY `id` DESC
LIMIT %s
]]):format(limit), {})
local audit = {}
for _, row in ipairs(rows) do
audit[#audit + 1] = {
id = tonumber(row.id),
actorName = row.actor_name,
targetIdentifier = row.target_identifier,
targetSource = row.target_source and tonumber(row.target_source) or nil,
deviceImei = row.device_imei,
action = row.action,
details = json.decode(row.details),
createdAt = row.created_at,
}
end
return audit
end
Bridge.Callbacks.Register("sky_phone:admin:bootstrap", function(source)
local authorized, error_response = require_admin(
source,
"bootstrap",
Config.AdminPanel.ReadRequestsPerMinute
)
if not authorized then
return error_response
end
local players = list_players()
local totals = Bridge.Database.Query([[
SELECT
(SELECT COUNT(*) FROM `sky_phone_devices`) AS `devices`,
(SELECT COUNT(*) FROM `sky_phone_accounts`) AS `accounts`
]], {})
return {
success = true,
data = {
players = players,
stats = {
online = #players,
devices = tonumber(totals[1] and totals[1].devices) or 0,
accounts = tonumber(totals[1] and totals[1].accounts) or 0,
},
audit = load_audit(),
},
}
end)
Bridge.Callbacks.Register("sky_phone:admin:player", function(source, data)
local authorized, error_response = require_admin(
source,
"player",
Config.AdminPanel.ReadRequestsPerMinute
)
if not authorized then
return error_response
end
local target_source = normalize_source(data and data.source)
if not target_source then
return { success = false, error = "player_unavailable" }
end
return { success = true, data = load_player_detail(target_source) }
end)
Bridge.Callbacks.Register("sky_phone:admin:save-apps", function(source, data)
local authorized, error_response = require_admin(
source,
"save_apps",
Config.AdminPanel.ActionRequestsPerMinute
)
if not authorized then
return error_response
end
if type(data) ~= "table"
or not SkyPhoneImei.IsValid(data.imei)
or type(data.revision) ~= "number"
or data.revision < 0
or data.revision ~= math.floor(data.revision)
or type(data.changes) ~= "table"
or #data.changes < 1
or #data.changes > 128
then
return { success = false, error = "invalid_request" }
end
local target_source = normalize_source(data.source)
if not target_source then
return { success = false, error = "player_unavailable" }
end
local target_device, target_identifier = find_owned_device(target_source, data.imei)
if not target_device then
return { success = false, error = "device_not_owned" }
end
local normalized_changes = {}
local seen_apps = {}
for index = 1, #data.changes do
local change = data.changes[index]
if type(change) ~= "table"
or type(change.appId) ~= "string"
or #change.appId < 1
or #change.appId > 64
or type(change.installed) ~= "boolean"
or seen_apps[change.appId]
then
return { success = false, error = "invalid_request" }
end
local metadata = app_metadata(change.appId)
if not metadata then
return { success = false, error = "invalid_app" }
end
if not change.installed and not metadata.removable then
return { success = false, error = "app_protected" }
end
seen_apps[change.appId] = true
normalized_changes[#normalized_changes + 1] = {
appId = change.appId,
installed = change.installed,
metadata = metadata,
}
end
local rows = Bridge.Database.Query([[
SELECT `payload`, `revision`
FROM `sky_phone_device_data`
WHERE `device_imei` = ? AND `namespace` = 'apps'
LIMIT 1
]], { data.imei })
local current_revision = tonumber(rows[1] and rows[1].revision) or 0
if current_revision ~= data.revision then
return { success = false, error = "revision_conflict" }
end
local payload, claimed_apps, uninstalled_apps = load_app_payload(rows[1] and rows[1].payload)
for index = 1, #normalized_changes do
local change = normalized_changes[index]
if change.installed then
uninstalled_apps = remove_app_id(uninstalled_apps, change.appId)
if not change.metadata.defaultInstalled then
claimed_apps = add_app_id(claimed_apps, change.appId)
end
else
claimed_apps = remove_app_id(claimed_apps, change.appId)
uninstalled_apps = add_app_id(uninstalled_apps, change.appId)
end
end
payload.claimedApps = claimed_apps
payload.uninstalledApps = #uninstalled_apps > 0 and uninstalled_apps or nil
local encoded = json.encode(payload)
if #encoded > 100000 then
return { success = false, error = "payload_too_large" }
end
if rows[1] then
local result = Bridge.Database.Query([[
UPDATE `sky_phone_device_data`
SET `payload` = ?, `revision` = `revision` + 1
WHERE `device_imei` = ? AND `namespace` = 'apps' AND `revision` = ?
]], { encoded, data.imei, data.revision })
if affected_rows(result) ~= 1 then
return { success = false, error = "revision_conflict" }
end
else
local result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_device_data` (`device_imei`, `namespace`, `payload`)
VALUES (?, 'apps', ?)
]], { data.imei, encoded })
if affected_rows(result) ~= 1 then
return { success = false, error = "revision_conflict" }
end
end
for index = 1, #normalized_changes do
local change = normalized_changes[index]
write_audit(
source,
target_source,
target_identifier,
data.imei,
change.installed and "grant_app" or "revoke_app",
{ appId = change.appId }
)
end
SkyPhone.RefreshDevice(data.imei)
return { success = true, data = load_player_detail(target_source) }
end)
Bridge.Callbacks.Register("sky_phone:admin:reveal-password", function(source, data)
local authorized, error_response = require_admin(
source,
"reveal_password",
Config.AdminPanel.CredentialRevealsPerMinute
)
if not authorized then
return error_response
end
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
return { success = false, error = "invalid_request" }
end
local target_source = normalize_source(data.source)
if not target_source then
return { success = false, error = "player_unavailable" }
end
local device, target_identifier = find_owned_device(target_source, data.imei)
if not device then
return { success = false, error = "device_not_owned" }
end
local accounts = Bridge.Database.Query([[
SELECT account.`email`, account.`password`
FROM `sky_phone_devices` device
JOIN `sky_phone_accounts` account ON account.`id` = device.`account_id`
WHERE device.`imei` = ?
LIMIT 1
]], { data.imei })
if not accounts[1] then
return { success = false, error = "account_not_found" }
end
write_audit(
source,
target_source,
target_identifier,
data.imei,
"reveal_account_password",
{ email = accounts[1].email }
)
return {
success = true,
data = {
email = accounts[1].email,
password = accounts[1].password,
},
}
end)
Bridge.Callbacks.Register("sky_phone:admin:activity", function(source, data)
local authorized, error_response = require_admin(
source,
"activity",
Config.AdminPanel.ReadRequestsPerMinute
)
if not authorized then
return error_response
end
if type(data) ~= "table"
or not SkyPhoneImei.IsValid(data.imei)
or (data.kind ~= "messages" and data.kind ~= "calls")
then
return { success = false, error = "invalid_request" }
end
local target_source = normalize_source(data.source)
if not target_source then
return { success = false, error = "player_unavailable" }
end
local device, target_identifier = find_owned_device(target_source, data.imei)
if not device then
return { success = false, error = "device_not_owned" }
end
local audit_action = data.kind == "messages" and "view_messages" or "view_calls"
local sim = load_device_sim(data.imei)
if not sim then
write_audit(
source,
target_source,
target_identifier,
data.imei,
audit_action,
{ count = 0 }
)
return { success = true, data = { kind = data.kind, entries = {} } }
end
local limit = math.max(1, math.min(100, math.floor(tonumber(Config.AdminPanel.ActivityLimit) or 40)))
local entries = {}
if data.kind == "messages" then
local rows = Bridge.Database.Query(([[
SELECT `id`, `sender_sim_id`, `sender_number`, `recipient_number`, `message_type`,
`body`, `read_at`, `created_at`
FROM `sky_phone_sms_messages`
WHERE `sender_sim_id` = ? OR `recipient_sim_id` = ?
ORDER BY `created_at` DESC
LIMIT %s
]]):format(limit), { sim.id, sim.id })
for _, row in ipairs(rows) do
local outgoing = row.sender_sim_id == sim.id
entries[#entries + 1] = {
id = row.id,
direction = outgoing and "outgoing" or "incoming",
otherNumber = outgoing and row.recipient_number or row.sender_number,
messageType = row.message_type,
body = row.body,
readAt = row.read_at,
createdAt = row.created_at,
}
end
else
local rows = Bridge.Database.Query(([[
SELECT `id`, `caller_sim_id`, `caller_number`, `callee_number`, `status`,
`started_at`, `answered_at`, `ended_at`, `duration_seconds`
FROM `sky_phone_calls`
WHERE `caller_sim_id` = ? OR `callee_sim_id` = ?
ORDER BY `started_at` DESC
LIMIT %s
]]):format(limit), { sim.id, sim.id })
for _, row in ipairs(rows) do
local outgoing = row.caller_sim_id == sim.id
entries[#entries + 1] = {
id = row.id,
direction = outgoing and "outgoing" or "incoming",
otherNumber = outgoing and row.callee_number or row.caller_number,
status = row.status,
startedAt = row.started_at,
answeredAt = row.answered_at,
endedAt = row.ended_at,
durationSeconds = tonumber(row.duration_seconds) or 0,
}
end
end
write_audit(
source,
target_source,
target_identifier,
data.imei,
audit_action,
{ count = #entries }
)
return { success = true, data = { kind = data.kind, entries = entries } }
end)
Bridge.Callbacks.Register("sky_phone:admin:reset-passcode", function(source, data)
local authorized, error_response = require_admin(
source,
"reset_passcode",
Config.AdminPanel.ActionRequestsPerMinute
)
if not authorized then
return error_response
end
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
return { success = false, error = "invalid_request" }
end
local target_source = normalize_source(data.source)
if not target_source then
return { success = false, error = "player_unavailable" }
end
local device, target_identifier = find_owned_device(target_source, data.imei)
if not device then
return { success = false, error = "device_not_owned" }
end
local result = Bridge.Database.Query(
"DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
{ data.imei }
)
if affected_rows(result) ~= 1 then
return { success = false, error = "passcode_not_set" }
end
write_audit(source, target_source, target_identifier, data.imei, "reset_passcode", {})
SkyPhone.RefreshDevice(data.imei)
return { success = true, data = load_player_detail(target_source) }
end)
Bridge.Callbacks.Register("sky_phone:admin:change-number", function(source, data)
local authorized, error_response = require_admin(
source,
"change_number",
Config.AdminPanel.ActionRequestsPerMinute
)
if not authorized then
return error_response
end
if type(data) ~= "table"
or not SkyPhoneImei.IsValid(data.imei)
or (type(data.phoneNumber) ~= "string" and type(data.phoneNumber) ~= "number")
then
return { success = false, error = "invalid_request" }
end
local target_source = normalize_source(data.source)
if not target_source then
return { success = false, error = "player_unavailable" }
end
local device, target_identifier = find_owned_device(target_source, data.imei)
if not device then
return { success = false, error = "device_not_owned" }
end
local sim = load_device_sim(data.imei)
if not sim then
return { success = false, error = "no_sim" }
end
local changed, number_or_error = SkyPhoneSim.ChangeNumber(
target_source,
data.imei,
sim.id,
data.phoneNumber
)
if not changed then
return { success = false, error = number_or_error }
end
SkyPhoneCompanies.ClearCallAvailability(target_source)
SkyPhoneCalls.EndForSim(sim.id, "number_changed")
write_audit(source, target_source, target_identifier, data.imei, "change_number", {
previousNumber = sim.phone_number,
phoneNumber = number_or_error,
})
SkyPhone.RefreshDevice(data.imei)
return { success = true, data = load_player_detail(target_source) }
end)
Bridge.Callbacks.Register("sky_phone:admin:factory-reset", function(source, data)
local authorized, error_response = require_admin(
source,
"factory_reset",
Config.AdminPanel.ActionRequestsPerMinute
)
if not authorized then
return error_response
end
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
return { success = false, error = "invalid_request" }
end
local target_source = normalize_source(data.source)
if not target_source then
return { success = false, error = "player_unavailable" }
end
local device, target_identifier = find_owned_device(target_source, data.imei)
if not device then
return { success = false, error = "device_not_owned" }
end
local reset, phone_number_or_error = SkyPhonePersistence.FactoryReset(data.imei)
if not reset then
return { success = false, error = phone_number_or_error }
end
write_audit(source, target_source, target_identifier, data.imei, "factory_reset", {})
if phone_number_or_error then
TriggerEvent("sky_phone:server:factoryReset", target_source, phone_number_or_error)
end
SkyPhone.RefreshDevice(data.imei)
return { success = true, data = load_player_detail(target_source) }
end)
end)
+12 -10
View File
@@ -679,16 +679,18 @@ function SkyPhoneCompanies.GetCallTargets(company_id)
end
for source, readiness in pairs(call_availability) do
local member = online[source] and call_member(source) or nil
local device = member and SkyPhone.LoadDevice(readiness.imei) or nil
local device_slots = device and SkyPhone.FindDeviceSlots(source, readiness.imei) or {}
local readiness_valid = member and device and device_slots[1]
and member.company_id == readiness.company_id
and device.sim_id == readiness.sim_id
and device.sim_type == "registered"
and device.registered_at ~= nil
if not readiness_valid then
call_availability[source] = nil
elseif readiness.company_id == company_id then
local device = member and current_device(source, true) or nil
if not member or not device or member.company_id ~= readiness.company_id
or readiness.company_id ~= company_id or readiness.imei ~= device.imei
or readiness.sim_id ~= device.sim_id
then
if not member or not device or member.company_id ~= readiness.company_id
or readiness.imei ~= (device and device.imei)
or readiness.sim_id ~= (device and device.sim_id)
then
call_availability[source] = nil
end
else
targets[#targets + 1] = {
source = source,
simId = device.sim_id,
-40
View File
@@ -416,46 +416,6 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_admin_audit",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{
name = "actor_identifier",
type = "VARCHAR(80) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{ name = "actor_name", type = "VARCHAR(120) NOT NULL" },
{
name = "target_identifier",
type = "VARCHAR(80) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{ name = "target_source", type = "INT UNSIGNED NULL" },
{
name = "device_imei",
type = "CHAR(15) NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{
name = "action",
type = "VARCHAR(48) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{ name = "details", type = "LONGTEXT NOT NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_admin_audit_created", columns = "(`created_at`, `id`)" },
{ name = "idx_sky_phone_admin_audit_target", columns = "(`target_identifier`, `created_at`)" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_notes",
columns = {
+2 -19
View File
@@ -51,23 +51,6 @@ local function session_owner(source)
}
end
local function device_owner(source, imei)
if not SkyPhoneImei.IsValid(imei) then
return nil, { success = false, error = "invalid_request" }
end
if not SkyPhone.FindDeviceSlots(source, imei)[1] then
return nil, { success = false, error = "owner_changed" }
end
local device = SkyPhone.LoadDevice(imei)
if not device then
return nil, { success = false, error = "device_not_found" }
end
return {
account_id = device.account_id and tonumber(device.account_id) or nil,
imei = imei,
}
end
local function owner_condition(owner, alias)
local prefix = alias and ("`%s`."):format(alias) or ""
if owner.account_id then
@@ -334,7 +317,7 @@ RegisterNetEvent("sky_phone:memos:request-upload", function(data)
upload_result(src, type(data) == "table" and data.correlationId or nil, false, "invalid_memo")
return
end
local owner, error_response = device_owner(src, data.deviceImei)
local owner, error_response = session_owner(src)
if not owner then
upload_result(src, memo.correlation_id, false, error_response.error)
return
@@ -409,7 +392,7 @@ RegisterNetEvent("sky_phone:memos:complete-upload", function(data)
return
end
state.completing = true
local owner, error_response = device_owner(src, state.owner.imei)
local owner, error_response = session_owner(src)
if not owner or owner.imei ~= state.owner.imei or owner.account_id ~= state.owner.account_id then
pending_uploads[request_id] = nil
local rejected, _, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url)
+1
View File
@@ -958,6 +958,7 @@ Bridge.Debug(
)
Bridge.Callbacks.Register("sky_phone:device:close", function(source)
SkyPhoneCompanies.ClearCallAvailability(source)
sessions[source] = nil
return { success = true }
end)
+51 -69
View File
@@ -1,6 +1,4 @@
Bridge.Database.AfterMigration("sky_phone", function()
SkyPhonePersistence = {}
local max_device_data_bytes = 100000
local allowed_device_namespaces = {
settings = true,
@@ -152,69 +150,6 @@ Bridge.Callbacks.Register("sky_phone:notifications:save", function(source, data)
return { success = true }
end)
function SkyPhonePersistence.FactoryReset(imei)
if not SkyPhoneImei.IsValid(imei) then
return false, "invalid_request"
end
local device = SkyPhone.LoadDevice(imei)
if not device then
return false, "device_not_found"
end
local phone_number = device and device.phone_number or nil
local media_remote_ids = SkyPhoneMedia.GetDeviceRemoteIds(imei)
if not Bridge.Database.Transaction({
{
query = "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
params = { imei },
},
{
query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?",
params = { imei },
},
{
query = "DELETE FROM `sky_phone_custom_app_data` WHERE `device_imei` = ?",
params = { imei },
},
{
query = "DELETE FROM `sky_phone_notes` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { imei },
},
{
query = "DELETE FROM `sky_phone_media` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { imei },
},
{
query = "DELETE FROM `sky_phone_music_playlists` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { imei },
},
{
query = "DELETE FROM `sky_phone_music_youtube_songs` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { imei },
},
{
query = "DELETE FROM `sky_phone_contacts` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { imei },
},
{
query = "DELETE FROM `sky_phone_call_entries` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { imei },
},
{
query = "DELETE FROM `sky_phone_fliptok_sessions` WHERE `device_imei` = ?",
params = { imei },
},
{
query = "UPDATE `sky_phone_devices` SET `account_id` = NULL, `device_name` = ? WHERE `imei` = ?",
params = { Config.Phone.DeviceName, imei },
},
}) then
return false, "request_failed"
end
SkyPhoneMedia.CleanupRemoteFiles(media_remote_ids)
return true, phone_number
end
Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
if not SkyPhone.AllowOperation(source, "factory_reset", 3, 60) then
return { success = false, error = "rate_limited" }
@@ -223,11 +158,58 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
if not session then
return error_response
end
local reset, phone_number = SkyPhonePersistence.FactoryReset(session.imei)
if not reset then
return { success = false, error = phone_number }
local device = SkyPhone.LoadDevice(session.imei)
local phone_number = device and device.phone_number or nil
local media_remote_ids = SkyPhoneMedia.GetDeviceRemoteIds(session.imei)
if not Bridge.Database.Transaction({
{
query = "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_custom_app_data` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_notes` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_media` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_music_playlists` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_music_youtube_songs` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_contacts` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_call_entries` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_fliptok_sessions` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "UPDATE `sky_phone_devices` SET `account_id` = NULL, `device_name` = ? WHERE `imei` = ?",
params = { Config.Phone.DeviceName, session.imei },
},
}) then
return { success = false, error = "request_failed" }
end
SkyPhoneMedia.CleanupRemoteFiles(media_remote_ids)
session.unlocked = true
if phone_number then
TriggerEvent("sky_phone:server:factoryReset", source, phone_number)
-56
View File
@@ -153,62 +153,6 @@ end
SkyPhoneSim.PrepareDevice = prepare_device
function SkyPhoneSim.ChangeNumber(source, imei, sim_id, value)
local number = SkyPhoneSimNumber.Normalize(value, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not number or SkyPhoneCompanies.IsServiceNumber(number) then
return false, "invalid_phone_number"
end
local sim = load_sim(sim_id)
if not sim then
return false, "no_sim"
end
if sim.phone_number == number then
return false, "phone_number_unchanged"
end
local existing = Bridge.Database.Query(
"SELECT `id` FROM `sky_phone_sims` WHERE `phone_number` = ? AND `id` <> ? LIMIT 1",
{ number, sim_id }
)
if existing[1] then
return false, "phone_number_taken"
end
local phone_slot
if unique_phones then
for _, slot in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
if slot.metadata and slot.metadata.imei == imei then
phone_slot = slot
break
end
end
end
local previous_number = sim.phone_number
sim.phone_number = number
if phone_slot and not set_phone_sim_metadata(source, phone_slot, sim) then
return false, "metadata_unsupported"
end
local result = Bridge.Database.Query([[
UPDATE IGNORE `sky_phone_sims`
SET `phone_number` = ?
WHERE `id` = ? AND `phone_number` = ?
]], { number, sim_id, previous_number })
if affected_rows(result) ~= 1 then
if phone_slot then
sim.phone_number = previous_number
if not set_phone_sim_metadata(source, phone_slot, sim) then
error("[sky_phone] Could not restore SIM metadata after a failed admin number change.")
end
end
return false, "phone_number_taken"
end
return true, number
end
local function resolve_used_sim(source, used_item, item_name)
local slot_id = used_item and (used_item.slot or used_item.id)
local slot = slot_id and Bridge.Inventory.GetSlot(source, slot_id) or nil
-1
View File
@@ -35,7 +35,6 @@ local ALLOWED_PERMISSIONS = {
}
local RESERVED_APP_IDS = {
admin = true,
["app-store"] = true,
banking = true,
crypto = true,
-15
View File
@@ -185,21 +185,6 @@ CREATE TABLE IF NOT EXISTS `sky_phone_device_security` (
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_admin_audit` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`actor_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`actor_name` VARCHAR(120) NOT NULL,
`target_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`target_source` INT UNSIGNED NULL,
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NULL,
`action` VARCHAR(48) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`details` LONGTEXT NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_sky_phone_admin_audit_created` (`created_at`, `id`),
KEY `idx_sky_phone_admin_audit_target` (`target_identifier`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_notes` (
`id` VARCHAR(64) NOT NULL,
`account_id` BIGINT UNSIGNED NULL,
+1 -16
View File
@@ -23,11 +23,7 @@ Bridge = {
},
Debug = function()
end,
Framework = {
HasAdminGroup = function()
return true
end,
},
Framework = {},
Inventory = {
GetResourceName = function()
return "test_inventory"
@@ -40,11 +36,6 @@ Bridge = {
}
Config = {
AdminPanel = {
AdminGroups = { "admin" },
Command = "phoneadmin",
Enabled = true,
},
Phone = {
DevelopmentCommand = false,
DeviceName = "Test Phone",
@@ -88,12 +79,6 @@ function AddEventHandler(_, callback)
assert(type(callback) == "function")
end
function RegisterCommand(name, callback, restricted)
assert(name == "phoneadmin")
assert(type(callback) == "function")
assert(restricted == false)
end
function TriggerClientEvent()
end