Compare commits

..
Author SHA1 Message Date
Leon.Schmidt fe38dc6efa FIX - match FiveManage memo upload flow 2026-08-21 01:06:00 +02:00
Leon.Schmidt 10afed3d2c FIX - match FiveManage camera upload flow 2026-08-21 01:06:00 +02:00
Leon.Schmidt 37948e1f6d FIX - bind FiveManage uploads by storage key 2026-08-21 01:05:59 +02:00
Leon.Schmidt 9574c0ca58 TRY - verify unindexed FiveManage storage objects 2026-08-21 01:05:58 +02:00
Leon.Schmidt d32e6b5584 TRY - isolate FiveManage media upload verification 2026-08-21 01:05:58 +02:00
Alec Schitzkat 7bbafd0026 BLD - Bump version to 0.2.0 2026-08-20 21:35:31 +02:00
DerEchteAlec 300329825f Merge pull request #4 from sky-systems/feat/inter-font
ENH - use Inter across phone UI and improve home label readability
2026-08-20 19:21:05 +02:00
DerEchteAlec 93d2263f14 FIX - resolve PR 4 merge conflict 2026-08-20 19:18:23 +02:00
DerEchteAlec dca0868753 FIX - stabilize homescreen dragging across scaling (#18)
* FIX - stabilize homescreen dragging across scaling

Render the home drag preview in an unzoomed viewport portal and normalize FiveM CEF DOM geometry before hit testing and drop settling. Cover Chrome 103, fractional scaling, 4K, ultrawide, and non-16:9 viewport behavior.

* FIX - Implement LB guest app layout and lifecycle bridge

Ensure custom LB apps are properly sized and visible by applying the required CSS contract on initialization and upon receiving the 'componentsLoaded' signal.

* FIX - match uppercase script tags in LB bridge test
2026-08-20 19:14:00 +02:00
Leon.SchmidtandDerEchteAlec 36b859cabb ENH - add persistent dynamic island activities (#17)
* FIX - layer dynamic island above phone camera

The phone screen stacking context kept the Dynamic Island below the physical frame and its camera lens. Render the island at device-shell level, position it around the lens, and keep expanded notification spacing in sync.

* ADD - preview all dynamic island states

Reuse the runtime component for a development-only gallery and size expanded previews to the actual phone display so they cannot be clipped.

* FIX - match dynamic island live activity behavior

Show each activity only outside its owning app, use reference-sized activity layouts, and remove the separate expand glyph in favor of direct island taps.

* FIX - refine dynamic island live controls

Keep background activity state separate from foreground visibility so the island remains visible in the compact closed-phone peek. Match the timer, stopwatch, music, collapse, and spacing behavior to the supplied references.

* FIX - keep business call availability after closing

Stop treating the closed phone UI as the end of business-call readiness. Route background service-line calls only after revalidating the employee, owned device, and registered SIM.

* FIX - remove dock apps from home grid

The stored Home layout treated dock and grid shortcuts independently, so default dock apps appeared twice. Let the dock own those default placements and migrate saved layouts by removing matching grid shortcuts.

* FIX - keep memo island active after closing

Memo recording was cancelled as soon as the UI closed, while uploads required an active UI session and reopening a locked phone forced the home route. Keep the recorder bound to the physical device, revalidate device ownership server-side for uploads, preserve the last route behind the lock screen, and render only a compact frame, display, and live Dynamic Island while closed.

* ENH - align live activity controls with phone hardware

Report compact live-activity state to the client so Space opens Home or the enabled lock screen contextually. Apply the phone hardware volume as a shared output level for app media, games, YouTube playback, radio, and auxiliary sounds, and render the volume speaker icon in grey.

* FIX - hide paused music live activity

The music island previously depended only on the selected track, which remains set after playback pauses. Require active playback as well so the island closes when music stops while preserving the track for later resume.

---------

Co-authored-by: DerEchteAlec <bycraky@gmail.com>
2026-08-20 18:59:51 +02:00
DerEchteAlecandsmx.pusha d0317a35c0 ENH - refine phone setup (#15)
* ENH - refine phone setup experience

* FIX - refine settings header and search icon

* FIX - refine compact weather layout

* ENH - fade empty music widget content

* ENH - align gallery filter navigation

* FIX - align memo player content

* ENH - refine notes header and compose controls

* ENH - refine phone liquid glass controls

* ENH - apply liquid glass across app controls

* FIX - correct messages GIF picker layout

---------

Co-authored-by: smx.pusha <139338836+smxpusha@users.noreply.github.com>
2026-08-20 18:26:03 +02:00
DerEchteAlec 074117fe33 Merge branch 'dev' into feat/inter-font 2026-08-19 20:11:48 +02:00
DerEchteAlec a1025f4520 Merge branch 'dev' into feat/inter-font 2026-08-19 19:21:42 +02:00
DerEchteAlec 7b8bab75a8 Merge branch 'dev' into feat/inter-font 2026-08-19 15:56:56 +02:00
DerEchteAlec c0492a70cb BLD - merge dev into Inter font branch 2026-08-19 15:34:49 +02:00
DerEchteAlec 05169acbfe BLD - add GitHub contribution and release automation 2026-08-19 14:20:46 +02:00
DerEchteAlec 086b77e464 ENH - enlarge home screen labels 2026-08-19 13:52:06 +02:00
DerEchteAlec de613e2502 ENH - use Inter across phone UI 2026-08-19 13:41:45 +02:00
84 changed files with 3331 additions and 572 deletions
+94
View File
@@ -26,6 +26,100 @@ 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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. 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 ## Framework7 documentation placeholder media
The development-only Sky UI Kitchen Sink includes placeholder images mirrored The development-only Sky UI Kitchen Sink includes placeholder images mirrored
+78 -6
View File
@@ -56,6 +56,7 @@ import { useNotesStore } from '@/stores/notes'
import { useMemosStore } from '@/stores/memos' import { useMemosStore } from '@/stores/memos'
import { useWeatherStore } from '@/stores/weather' import { useWeatherStore } from '@/stores/weather'
import { useEasyShareStore } from '@/stores/easyshare' import { useEasyShareStore } from '@/stores/easyshare'
import { useRadioStore } from '@/stores/radio'
import { import {
useNotificationsStore, useNotificationsStore,
type PhoneNotification, type PhoneNotification,
@@ -70,10 +71,15 @@ import type {
CompanyUnreadCounts, CompanyUnreadCounts,
} from '@/types/companies' } from '@/types/companies'
import type { PhoneCall } from '@/types/phone' import type { PhoneCall } from '@/types/phone'
import type { DynamicIslandActivity } from '@/types/dynamicIsland'
import type { EasyShareEvent } from '@/types/easyshare' import type { EasyShareEvent } from '@/types/easyshare'
import type { CryptoMarketChangedData } from '@/types/crypto' import type { CryptoMarketChangedData } from '@/types/crypto'
import type { CityWarnEventData } from '@/types/citywarn' import type { CityWarnEventData } from '@/types/citywarn'
import { nuiCall } from '@/utils/nui' import { nuiCall } from '@/utils/nui'
import {
installPhoneAudioController,
setPhoneOutputVolume,
} from '@/utils/phoneAudio'
import { formatTimer } from '@/utils/clock' import { formatTimer } from '@/utils/clock'
import { parsePhonePreferences } from '@/utils/preferences' import { parsePhonePreferences } from '@/utils/preferences'
import { getHairlinePixelStyle } from '@/utils/rendering' import { getHairlinePixelStyle } from '@/utils/rendering'
@@ -82,6 +88,7 @@ import { isTrustedRootMessageSource } from '@/utils/windowMessages'
import SpringboardView from '@/views/SpringboardView.vue' import SpringboardView from '@/views/SpringboardView.vue'
type AppMessage = { type AppMessage = {
openHome?: boolean
type?: string type?: string
data?: data?:
| CalendarReminderData | CalendarReminderData
@@ -313,6 +320,7 @@ const notes = useNotesStore()
const memos = useMemosStore() const memos = useMemosStore()
const weather = useWeatherStore() const weather = useWeatherStore()
const easyShare = useEasyShareStore() const easyShare = useEasyShareStore()
const radio = useRadioStore()
const notifications = useNotificationsStore() const notifications = useNotificationsStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -335,8 +343,13 @@ const DARK_STATUS_BAR_APP_IDS = new Set([
'minesweeper', 'minesweeper',
'number-merge', 'number-merge',
]) ])
const isDynamicIslandGalleryRoute = computed(
() => isDevelopment && route.name === 'development-dynamic-islands',
)
const isDevelopmentRoute = computed( const isDevelopmentRoute = computed(
() => isDevelopment && route.name === 'development-sky-ui', () =>
isDevelopment &&
(route.name === 'development-sky-ui' || isDynamicIslandGalleryRoute.value),
) )
const appTransitionName = computed(() => const appTransitionName = computed(() =>
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window', route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
@@ -356,9 +369,12 @@ const setupPreviewDismissed = ref(false)
const setupDevelopmentSkipped = ref(false) const setupDevelopmentSkipped = ref(false)
const setupAppearanceSelected = ref(false) const setupAppearanceSelected = ref(false)
const pendingUnlockRoute = ref<string | null>(null) const pendingUnlockRoute = ref<string | null>(null)
const openHomeRequested = ref(false)
const unlockedServicesLoaded = ref(false) const unlockedServicesLoaded = ref(false)
const controlCenterOpened = ref(false) const controlCenterOpened = ref(false)
const activitySuspended = ref(false) const activitySuspended = ref(false)
const dynamicIslandExpanded = ref(false)
const dynamicIslandActivity = ref<DynamicIslandActivity | null>(null)
const simPicker = ref<SimPickerPayload | null>(null) const simPicker = ref<SimPickerPayload | null>(null)
const setupRequired = computed( const setupRequired = computed(
() => () =>
@@ -432,6 +448,12 @@ const phoneResolutionStyle = computed<CSSProperties>(() => ({
})) }))
const phoneStageStyle = computed<CSSProperties>(() => ({ const phoneStageStyle = computed<CSSProperties>(() => ({
...phoneResolutionStyle.value, ...phoneResolutionStyle.value,
'--phone-live-activity-peek-height':
dynamicIslandActivity.value === 'music'
? '190px'
: dynamicIslandActivity.value === 'recording'
? '132px'
: '112px',
visibility: activitySuspended.value ? 'hidden' : 'visible', visibility: activitySuspended.value ? 'hidden' : 'visible',
})) }))
const phoneDisplayStyle = computed<CSSProperties>(() => ({ const phoneDisplayStyle = computed<CSSProperties>(() => ({
@@ -449,6 +471,7 @@ let unlockTimer: number | undefined
let passcodeLockTimer: number | undefined let passcodeLockTimer: number | undefined
let hardwareVolumeHudTimer: number | undefined let hardwareVolumeHudTimer: number | undefined
let unlockedServicesIdle: number | undefined let unlockedServicesIdle: number | undefined
let removePhoneAudioController: (() => void) | undefined
let phoneClosePending = false let phoneClosePending = false
let simPickerClosePending = false let simPickerClosePending = false
@@ -758,6 +781,7 @@ function onMessage(event: MessageEvent<AppMessage>): void {
}) })
} }
} else if (event.data?.type === 'app:open') { } else if (event.data?.type === 'app:open') {
openHomeRequested.value = event.data.openHome === true
hydratePhone(event.data.data as PhoneOpenPayload) hydratePhone(event.data.data as PhoneOpenPayload)
void syncNavigationState().then(() => nuiCall('ui:opened')) void syncNavigationState().then(() => nuiCall('ui:opened'))
} else if (event.data?.type === 'device:updated') { } else if (event.data?.type === 'device:updated') {
@@ -1473,6 +1497,7 @@ function onFocusOut(event: FocusEvent): void {
} }
onMounted(() => { onMounted(() => {
removePhoneAudioController = installPhoneAudioController()
document.addEventListener('focusin', onFocusIn) document.addEventListener('focusin', onFocusIn)
document.addEventListener('focusout', onFocusOut) document.addEventListener('focusout', onFocusOut)
window.addEventListener('message', onMessage) window.addEventListener('message', onMessage)
@@ -1587,6 +1612,23 @@ 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( watch(
() => phone.isOpen, () => phone.isOpen,
(isOpen) => { (isOpen) => {
@@ -1619,7 +1661,8 @@ watch(
} }
isLocked.value = setupRequired.value isLocked.value = setupRequired.value
? false ? false
: !isDevelopment || developmentLockScreenPreview : developmentLockScreenPreview ||
(!isDevelopment && phone.security.enabled)
passcodeRequired.value = isLocked.value && phone.security.enabled passcodeRequired.value = isLocked.value && phone.security.enabled
unlockedServicesLoaded.value = false unlockedServicesLoaded.value = false
controlCenterOpened.value = false controlCenterOpened.value = false
@@ -1637,8 +1680,18 @@ watch(
startPasscodeLock(passcodeRetrySeconds.value) startPasscodeLock(passcodeRetrySeconds.value)
} }
phone.setLaunchOrigin(null) phone.setLaunchOrigin(null)
if (isLocked.value || setupRequired.value) void router.replace('/') if (setupRequired.value) {
else loadUnlockedPhoneData() 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()
}
}, },
) )
@@ -1650,6 +1703,7 @@ watch(
) )
onBeforeUnmount(() => { onBeforeUnmount(() => {
removePhoneAudioController?.()
updateTextInputFocus(false) updateTextInputFocus(false)
cancelUnlockedPhoneDataLoad() cancelUnlockedPhoneDataLoad()
weather.stop() weather.stop()
@@ -1688,12 +1742,15 @@ onBeforeUnmount(() => {
phone.isOpen || phone.isOpen ||
notifications.current || notifications.current ||
calls.activeCall || calls.activeCall ||
dynamicIslandActivity ||
notifications.devicePreviews.length notifications.devicePreviews.length
" "
class="phone-stage" class="phone-stage"
:class="{ :class="{
'phone-stage--browser-preview': isBrowserPreview, 'phone-stage--browser-preview': isBrowserPreview,
'phone-stage--landscape': phone.cameraLandscape, 'phone-stage--landscape': phone.cameraLandscape,
'phone-stage--live-activity':
!phone.isOpen && Boolean(dynamicIslandActivity || calls.activeCall),
'phone-stage--peek': notifications.isPeeking, 'phone-stage--peek': notifications.isPeeking,
}" }"
:style="phoneStageStyle" :style="phoneStageStyle"
@@ -1713,14 +1770,25 @@ onBeforeUnmount(() => {
@open="openNotificationPreview" @open="openNotificationPreview"
/> />
<div <div
v-if="phone.isOpen || notifications.current || calls.activeCall" v-if="
phone.isOpen ||
notifications.current ||
calls.activeCall ||
dynamicIslandActivity
"
class="phone-resolution-wrapper phone-resolution-wrapper--primary" 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"> <div class="phone-resolution-canvas phone-resolution-canvas--primary">
<section <section
class="phone-device" class="phone-device"
:class="{ :class="{
'phone-app--light': !displayedDarkMode, 'phone-app--light': !displayedDarkMode,
'phone-device--island-expanded': dynamicIslandExpanded,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true, [`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}" }"
:aria-label="phone.t('Common.phone')" :aria-label="phone.t('Common.phone')"
@@ -1832,7 +1900,6 @@ onBeforeUnmount(() => {
@control-center="toggleControlCenter" @control-center="toggleControlCenter"
@lock="lockPhone" @lock="lockPhone"
/> />
<PhoneDynamicIsland v-if="!setupRequired" />
<SpringboardView <SpringboardView
v-if="!isDevelopmentRoute && !setupRequired" v-if="!isDevelopmentRoute && !setupRequired"
@edit-mode-change="springboardEditing = $event" @edit-mode-change="springboardEditing = $event"
@@ -1918,6 +1985,11 @@ onBeforeUnmount(() => {
aria-hidden="true" aria-hidden="true"
draggable="false" draggable="false"
/> />
<PhoneDynamicIsland
v-if="!setupRequired && !isDynamicIslandGalleryRoute"
@expanded-change="dynamicIslandExpanded = $event"
@live-activity-change="dynamicIslandActivity = $event"
/>
</section> </section>
</div> </div>
</div> </div>
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8') const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8').replace(/\r\n/g, '\n')
const mainCss = readFileSync( const mainCss = readFileSync(
new URL('./assets/main.css', import.meta.url), new URL('./assets/main.css', import.meta.url),
'utf8', 'utf8',
@@ -22,15 +22,32 @@ describe('browser development preview contract', () => {
it('starts unlocked while preserving an explicit lock screen preview', () => { it('starts unlocked while preserving an explicit lock screen preview', () => {
expect(source).toContain("developmentParameters.has('lockScreenPreview')") expect(source).toContain("developmentParameters.has('lockScreenPreview')")
expect(source).toContain(': !isDevelopment || developmentLockScreenPreview') expect(source).toContain(
'developmentLockScreenPreview ||\n (!isDevelopment && phone.security.enabled)',
)
expect(source).toContain("developmentParameters.has('setupPreview')") expect(source).toContain("developmentParameters.has('setupPreview')")
}) })
it('loads authenticated app data without replacing direct app routes', () => { it('restores the active route after the lock screen', () => {
expect(source).toContain( expect(source).toMatch(
/if \(setupRequired\.value\) \{[\s\S]*?router\.replace\('\/'\)/,
)
expect(source).toMatch(
/else if \(!isLocked\.value\) \{\s*loadUnlockedPhoneData\(\)/,
)
expect(source).not.toContain(
"if (isLocked.value || setupRequired.value) void router.replace('/')", "if (isLocked.value || setupRequired.value) void router.replace('/')",
) )
expect(source).toContain('else loadUnlockedPhoneData()') })
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('/')")
}) })
it('requires the passcode again after a full device lock', () => { it('requires the passcode again after a full device lock', () => {
@@ -75,6 +92,10 @@ describe('browser development preview contract', () => {
it('uses layout zoom so the fixed-resolution phone stays sharply rasterized', () => { it('uses layout zoom so the fixed-resolution phone stays sharply rasterized', () => {
expect(source).toContain('phone-resolution-canvas--primary') 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-height'")
expect(source).toContain("'--phone-rendered-width'") expect(source).toContain("'--phone-rendered-width'")
expect(mainCss).toMatch( expect(mainCss).toMatch(
@@ -100,7 +121,9 @@ describe('browser development preview contract', () => {
expect(source).toContain("developmentParameters.has('browserPreview')") expect(source).toContain("developmentParameters.has('browserPreview')")
expect(source).toContain('import.meta.env.DEV ||') expect(source).toContain('import.meta.env.DEV ||')
expect(source).toContain("'phone-stage--browser-preview': isBrowserPreview") 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( expect(mainCss).toMatch(
/\.phone-stage--browser-preview\s*\{[^}]*place-items:\s*center;[^}]*padding:\s*0;/s, /\.phone-stage--browser-preview\s*\{[^}]*place-items:\s*center;[^}]*padding:\s*0;/s,
) )
Binary file not shown.
Binary file not shown.
+70 -87
View File
@@ -211,9 +211,11 @@
} }
} }
:root { :root {
font-family: font-family: var(--sky-font-family);
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', font-feature-settings:
sans-serif; 'liga' 1,
'calt' 1;
font-optical-sizing: auto;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -234,7 +236,9 @@ body,
user-select: none; user-select: none;
} }
button, button,
input { input,
textarea,
select {
font: inherit; font: inherit;
} }
button { button {
@@ -259,6 +263,20 @@ button {
transform: translateY(calc(100% - 190px)); transform: translateY(calc(100% - 190px));
transform-origin: right bottom; 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 { .phone-lift-enter-active {
transition: opacity 0.52s linear; transition: opacity 0.52s linear;
} }
@@ -281,6 +299,18 @@ button {
height: var(--phone-rendered-height, 844px); 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 { .phone-resolution-canvas {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -384,7 +414,7 @@ button {
overflow: hidden; overflow: hidden;
border: 1px solid rgb(255 255 255 / 16%); border: 1px solid rgb(255 255 255 / 16%);
border-radius: 15px; border-radius: 15px;
color: #0a84ff; color: #a9abb2;
background: rgb(47 47 51 / 98%); background: rgb(47 47 51 / 98%);
box-shadow: 0 8px 24px rgb(0 0 0 / 28%); box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
pointer-events: none; pointer-events: none;
@@ -672,6 +702,9 @@ button {
transition: transform 280ms var(--sky-ease-out, ease-out); transition: transform 280ms var(--sky-ease-out, ease-out);
will-change: transform; will-change: transform;
} }
.phone-device--island-expanded .phone-notification {
top: 130px !important;
}
.phone-notification__icon { .phone-notification__icon {
width: 38px; width: 38px;
height: 38px; height: 38px;
@@ -795,78 +828,6 @@ button {
cursor: pointer; cursor: pointer;
pointer-events: auto; 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 { .phone-home-indicator {
position: absolute; position: absolute;
z-index: 90; z-index: 90;
@@ -1341,8 +1302,7 @@ button {
overflow: hidden; overflow: hidden;
background: #000; background: #000;
color: #f5f5f7; color: #f5f5f7;
font-family: font-family: var(--sky-font-family);
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
} }
.darkchat-page button, .darkchat-page button,
.darkchat-page input, .darkchat-page input,
@@ -2677,8 +2637,30 @@ button {
.springboard--widget-dragging .springboard-widget-page-scroll { .springboard--widget-dragging .springboard-widget-page-scroll {
overflow: visible; overflow: visible;
} }
.springboard--home-dragging .springboard-page--apps { .home-drag-layer {
overflow: visible; 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;
} }
.app-grid { .app-grid {
display: grid; display: grid;
@@ -2713,6 +2695,9 @@ button {
transition: transform var(--springboard-page-duration) transition: transform var(--springboard-page-duration)
var(--springboard-page-easing); var(--springboard-page-easing);
} }
.app-icon-item--drag-source {
opacity: 0;
}
.app-icon-remove { .app-icon-remove {
position: absolute; position: absolute;
z-index: 4; z-index: 4;
@@ -2838,7 +2823,6 @@ button {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 11px;
} }
.app-icon--calculator { .app-icon--calculator {
background: linear-gradient(145deg, #76767b, #1b1b1d); background: linear-gradient(145deg, #76767b, #1b1b1d);
@@ -7597,18 +7581,17 @@ button {
min-height: var(--springboard-grid-row); min-height: var(--springboard-grid-row);
} }
.app-icon-button { .app-icon-button {
font-family: font-family: var(--sky-font-family);
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
touch-action: none; touch-action: none;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
} }
.app-icon-label { .app-icon-label {
min-height: 15px; min-height: var(--sky-home-label-height);
font-size: 11.5px; font-size: var(--sky-home-label-font-size);
font-weight: 500; font-weight: 500;
letter-spacing: -0.15px; letter-spacing: -0.15px;
line-height: 15px; line-height: var(--sky-home-label-height);
text-align: center; text-align: center;
} }
.springboard-edit-add { .springboard-edit-add {
@@ -14,6 +14,10 @@ const callsServer = readFileSync(
new URL('../../sky_phone/source/server/calls.lua', import.meta.url), new URL('../../sky_phone/source/server/calls.lua', import.meta.url),
'utf8', 'utf8',
).replace(/\r\n/g, '\n') ).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( const companiesStore = readFileSync(
new URL('./stores/companies.ts', import.meta.url), new URL('./stores/companies.ts', import.meta.url),
'utf8', 'utf8',
@@ -85,3 +89,35 @@ describe('Companies outbound service-line call contract', () => {
expect(startCompanyCall).not.toContain('data.callerNumber') 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)')
})
})
+5 -2
View File
@@ -30,11 +30,13 @@ const props = withDefaults(
app: PhoneAppDefinition app: PhoneAppDefinition
compact?: boolean compact?: boolean
editMode?: boolean editMode?: boolean
externalDragVisual?: boolean
showLabel?: boolean showLabel?: boolean
}>(), }>(),
{ {
compact: false, compact: false,
editMode: false, editMode: false,
externalDragVisual: false,
showLabel: true, showLabel: true,
}, },
) )
@@ -63,14 +65,14 @@ let dragStartPage = 0
let dragPageWidth = 0 let dragPageWidth = 0
let dragPageMetrics: SpringboardDragMetrics | null = null let dragPageMetrics: SpringboardDragMetrics | null = null
const dragStyle = computed(() => const dragStyle = computed(() =>
isDragging.value isDragging.value && !props.externalDragVisual
? { ? {
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`, transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
} }
: undefined, : undefined,
) )
const dragPointerStyle = computed(() => const dragPointerStyle = computed(() =>
isDragging.value isDragging.value && !props.externalDragVisual
? { ? {
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`, transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
} }
@@ -300,6 +302,7 @@ onBeforeUnmount(() => {
class="app-icon-item" class="app-icon-item"
:class="{ :class="{
'app-icon-item--compact': compact, 'app-icon-item--compact': compact,
'app-icon-item--drag-source': isDragging && externalDragVisual,
'app-icon-item--dragging': isDragging, 'app-icon-item--dragging': isDragging,
'app-icon-item--editing': editMode, 'app-icon-item--editing': editMode,
}" }"
+5 -2
View File
@@ -22,11 +22,13 @@ const props = withDefaults(
apps: PhoneAppDefinition[] apps: PhoneAppDefinition[]
defaultName: string defaultName: string
editMode?: boolean editMode?: boolean
externalDragVisual?: boolean
folder: HomeFolder folder: HomeFolder
showLabel?: boolean showLabel?: boolean
}>(), }>(),
{ {
editMode: false, editMode: false,
externalDragVisual: false,
showLabel: true, showLabel: true,
}, },
) )
@@ -55,14 +57,14 @@ let stopPointerSession: (() => void) | null = null
const folderName = computed(() => props.folder.name || props.defaultName) const folderName = computed(() => props.folder.name || props.defaultName)
const dragStyle = computed(() => const dragStyle = computed(() =>
isDragging.value isDragging.value && !props.externalDragVisual
? { ? {
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`, transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
} }
: undefined, : undefined,
) )
const dragPointerStyle = computed(() => const dragPointerStyle = computed(() =>
isDragging.value isDragging.value && !props.externalDragVisual
? { ? {
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`, transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
} }
@@ -232,6 +234,7 @@ onBeforeUnmount(() => {
<div <div
class="home-folder-item app-icon-item" class="home-folder-item app-icon-item"
:class="{ :class="{
'app-icon-item--drag-source': isDragging && externalDragVisual,
'app-icon-item--dragging': isDragging, 'app-icon-item--dragging': isDragging,
'app-icon-item--editing': editMode, 'app-icon-item--editing': editMode,
'home-folder-item--dragging': isDragging, 'home-folder-item--dragging': isDragging,
@@ -331,8 +331,7 @@ function finishPageSwipe(event: PointerEvent): void {
z-index: 70; z-index: 70;
inset: 0; inset: 0;
color: #fff; color: #fff;
font-family: font-family: var(--sky-font-family);
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
} }
.home-folder-backdrop { .home-folder-backdrop {
+5 -2
View File
@@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import payphoneFrame from '@/assets/img/payphone/american-payphone-frame.png' import payphoneFrame from '@/assets/img/payphone/american-payphone-frame.png'
import { nuiCall } from '@/utils/nui' import { nuiCall } from '@/utils/nui'
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import { isTrustedRootMessageSource } from '@/utils/windowMessages' import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type PayphoneState = type PayphoneState =
@@ -70,7 +71,9 @@ const buttonSounds: HTMLAudioElement[] = []
function prepareButtonSounds(): void { function prepareButtonSounds(): void {
if (buttonSounds.length) return if (buttonSounds.length) return
for (let index = 0; index < 4; index += 1) { for (let index = 0; index < 4; index += 1) {
const sound = new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`) const sound = registerPhoneMediaElement(
new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`),
)
sound.preload = 'auto' sound.preload = 'auto'
sound.volume = 0.55 sound.volume = 0.55
buttonSounds.push(sound) buttonSounds.push(sound)
@@ -399,7 +402,7 @@ onBeforeUnmount(() => {
rgb(30 38 42 / 35%), rgb(30 38 42 / 35%),
rgb(0 0 0 / 84%) 72% rgb(0 0 0 / 84%) 72%
); );
font-family: 'Segoe UI', Arial, sans-serif; font-family: var(--sky-font-family);
pointer-events: auto; pointer-events: auto;
user-select: none; user-select: none;
} }
@@ -5,8 +5,15 @@ import { describe, expect, it } from 'vitest'
const source = readFileSync( const source = readFileSync(
new URL('./PhoneDynamicIsland.vue', import.meta.url), new URL('./PhoneDynamicIsland.vue', import.meta.url),
'utf8', '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( const recorderSource = readFileSync(
new URL('./PhoneMemoRecorder.vue', import.meta.url), new URL('./PhoneMemoRecorder.vue', import.meta.url),
'utf8', 'utf8',
@@ -19,16 +26,23 @@ const clockSource = readFileSync(
new URL('../views/apps/ClockApp.vue', import.meta.url), new URL('../views/apps/ClockApp.vue', import.meta.url),
'utf8', 'utf8',
) )
const serverMemosSource = readFileSync(
new URL('../../../sky_phone/source/server/memos.lua', import.meta.url),
'utf8',
)
describe('Phone Dynamic Island contract', () => { describe('Phone Dynamic Island contract', () => {
it('renders once at phone shell level instead of forcing calls into Phone', () => { it('renders once at phone shell level instead of forcing calls into Phone', () => {
expect(appSource).toContain( expect(appSource).toContain(
"import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'", "import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'",
) )
expect(appSource).toContain('<PhoneDynamicIsland v-if="!setupRequired" />')
expect(appSource).toContain( expect(appSource).toContain(
'phone.isOpen || notifications.current || calls.activeCall', "'phone-device--island-expanded': dynamicIslandExpanded",
) )
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( expect(appSource).not.toContain(
"window.setTimeout(() => void router.push('/apps/phone'), 0)", "window.setTimeout(() => void router.push('/apps/phone'), 0)",
) )
@@ -45,7 +59,44 @@ describe('Phone Dynamic Island contract', () => {
expect(source).toContain('call.answeredAt ?? call.startedAt') 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', () => { 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.previous()"')
expect(source).toContain('@click.stop="music.toggle()"') expect(source).toContain('@click.stop="music.toggle()"')
expect(source).toContain('@click.stop="music.next()"') expect(source).toContain('@click.stop="music.next()"')
@@ -53,9 +104,31 @@ describe('Phone Dynamic Island contract', () => {
expect(source).toContain('clock.pauseTimer(Date.now())') expect(source).toContain('clock.pauseTimer(Date.now())')
expect(source).toContain('clock.pauseStopwatch(Date.now())') expect(source).toContain('clock.pauseStopwatch(Date.now())')
expect(source).toContain('clock.addLap(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('keeps recorder state available across app changes', () => { 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', () => {
expect(recorderSource).toContain( expect(recorderSource).toContain(
"message.type === 'memo:recordStateRequest'", "message.type === 'memo:recordStateRequest'",
) )
@@ -65,6 +138,15 @@ describe('Phone Dynamic Island contract', () => {
expect(memosSource).not.toContain( expect(memosSource).not.toContain(
"if (recordingActive.value) postRecorderCommand('memo:recordCancel')", "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', () => { it('opens both clock live activities on the correct clock tab', () => {
@@ -75,13 +157,68 @@ 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', () => { 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">')
expect(source).toContain( expect(source).toContain(
'<Transition name="phone-dynamic-island-content" mode="out-in">', '<Transition name="phone-dynamic-island-content" mode="out-in">',
) )
expect(source).toContain(".phone-dynamic-island[data-expanded='true']") expect(source).toContain(".phone-dynamic-island[data-expanded='true']")
expect(source).toContain('~ .phone-notification-provider') expect(source).toContain("emit('expanded-change', false)")
expect(mainCss).toContain(
'.phone-device--island-expanded .phone-notification',
)
expect(source).toContain('@media (prefers-reduced-motion: reduce)') 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
+89 -14
View File
@@ -14,6 +14,12 @@ import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type RecordingChunk = { blob: Blob; durationMs: number } type RecordingChunk = { blob: Blob; durationMs: number }
type PendingVideo = { blob: Blob; fileName: string } type PendingVideo = { blob: Blob; fileName: string }
type UploadFailureDebug = {
correlationId: string
message: string
stage: string
status?: number
}
const canvasRef = ref<HTMLCanvasElement | null>(null) const canvasRef = ref<HTMLCanvasElement | null>(null)
const pendingVideos = new Map<string, PendingVideo>() const pendingVideos = new Map<string, PendingVideo>()
@@ -130,7 +136,10 @@ function cleanupRecording(): void {
try { try {
activeRecorder.stop() activeRecorder.stop()
} catch (error) { } catch (error) {
console.error('[Camera] Could not stop the failed media recorder.', error) console.error(
'[Camera] Could not stop the failed media recorder.',
error,
)
} }
} }
} }
@@ -245,8 +254,7 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
const activeRecorder = recorder const activeRecorder = recorder
removeRecorderErrorListener = bindMediaRecorderError( removeRecorderErrorListener = bindMediaRecorderError(
activeRecorder, activeRecorder,
() => () => generation === recordingGeneration && recorder === activeRecorder,
generation === recordingGeneration && recorder === activeRecorder,
(event) => { (event) => {
console.error('[Camera] Media recorder failed while recording.', event) console.error('[Camera] Media recorder failed while recording.', event)
cleanupRecording() cleanupRecording()
@@ -417,8 +425,32 @@ async function capturePhotoBlob(ready: UploadReady): Promise<Blob> {
} }
} }
async function failUpload(requestId: string, error: string): Promise<void> { async function failUpload(
await nuiCall('media:failUpload', { error, requestId }) requestId: string,
error: string,
debug: UploadFailureDebug,
): Promise<void> {
console.error('[Sky Phone Media] Upload failed.', {
correlationId: debug.correlationId,
detail: debug.message,
error,
stage: debug.stage,
status: debug.status,
})
const response = await nuiCall('media:failUpload', {
correlationId: debug.correlationId,
debugMessage: debug.message,
debugStage: debug.stage,
debugStatus: debug.status,
error,
requestId,
})
if (!response.success) {
console.error('[Sky Phone Media] Could not forward upload diagnostics.', {
correlationId: debug.correlationId,
error: response.error,
})
}
} }
async function uploadReady(ready: UploadReady): Promise<void> { async function uploadReady(ready: UploadReady): Promise<void> {
@@ -435,49 +467,87 @@ async function uploadReady(ready: UploadReady): Promise<void> {
blob = await capturePhotoBlob(ready) blob = await capturePhotoBlob(ready)
fileName = `camera-${ready.correlationId}.${ready.photo?.Encoding ?? 'jpg'}` fileName = `camera-${ready.correlationId}.${ready.photo?.Encoding ?? 'jpg'}`
} }
} catch { } catch (error) {
await failUpload(ready.requestId, 'capture_failed') await failUpload(ready.requestId, 'capture_failed', {
correlationId: ready.correlationId,
message: error instanceof Error ? error.message : String(error),
stage: 'capture',
})
return return
} }
console.info('[Sky Phone Media] Capture prepared for upload.', {
bytes: blob.size,
correlationId: ready.correlationId,
mimeType: blob.type,
type: ready.mediaType,
})
const form = new FormData() const form = new FormData()
form.append('file', blob, fileName) form.append('file', blob, fileName)
form.append(
'metadata',
JSON.stringify({ captureToken: ready.captureToken, source: 'sky_phone' }),
)
const controller = new AbortController() const controller = new AbortController()
const timeout = window.setTimeout( const timeout = window.setTimeout(
() => controller.abort(), () => controller.abort(),
ready.uploadTimeoutMs ?? 25000, ready.uploadTimeoutMs ?? 25000,
) )
let debugStage = 'provider_request'
let debugStatus: number | undefined
try { try {
const response = await fetch(ready.presignedUrl, { const response = await fetch(ready.presignedUrl, {
body: form, body: form,
method: 'POST', method: 'POST',
signal: controller.signal, signal: controller.signal,
}) })
debugStatus = response.status
debugStage = 'provider_response'
console.info('[Sky Phone Media] FiveManage upload responded.', {
correlationId: ready.correlationId,
status: response.status,
})
const text = await response.text() const text = await response.text()
const body = JSON.parse(text) as { const body = JSON.parse(text) as {
data?: { id?: string; url?: string } data?: { id?: string; url?: string }
error?: string
id?: string id?: string
message?: string
url?: string url?: string
} }
const uploaded = body.data ?? body const uploaded = body.data ?? body
if (!response.ok || !uploaded.id || !uploaded.url) { if (!response.ok || !uploaded.id || !uploaded.url) {
throw new Error('upload_failed') throw new Error(
(typeof body.error === 'string' && body.error) ||
(typeof body.message === 'string' && body.message) ||
'upload_failed',
)
} }
await nuiCall('media:completeUpload', { debugStage = 'completion_callback'
const completion = await nuiCall('media:completeUpload', {
correlationId: ready.correlationId,
remoteId: uploaded.id, remoteId: uploaded.id,
requestId: ready.requestId, requestId: ready.requestId,
url: uploaded.url, url: uploaded.url,
}) })
if (!completion.success) {
throw new Error(completion.error ?? 'completion_callback_failed')
}
console.info(
'[Sky Phone Media] Upload completion forwarded to the server.',
{
correlationId: ready.correlationId,
},
)
} catch (error) { } catch (error) {
await failUpload( await failUpload(
ready.requestId, ready.requestId,
error instanceof DOMException && error.name === 'AbortError' error instanceof DOMException && error.name === 'AbortError'
? 'upload_timeout' ? 'upload_timeout'
: 'upload_failed', : 'upload_failed',
{
correlationId: ready.correlationId,
message: error instanceof Error ? error.message : String(error),
stage: debugStage,
status: debugStatus,
},
) )
} finally { } finally {
window.clearTimeout(timeout) window.clearTimeout(timeout)
@@ -523,7 +593,12 @@ function onMessage(event: MessageEvent): void {
) )
} }
} else if (message.type === 'media:uploadReady') { } else if (message.type === 'media:uploadReady') {
void uploadReady(message.data as UploadReady) const ready = message.data as UploadReady
console.info('[Sky Phone Media] Upload-ready received.', {
correlationId: ready.correlationId,
type: ready.mediaType,
})
void uploadReady(ready)
} else if (message.type === 'media:uploadResult') { } else if (message.type === 'media:uploadResult') {
const correlationId = String(message.data?.correlationId ?? '') const correlationId = String(message.data?.correlationId ?? '')
if (correlationId) pendingVideos.delete(correlationId) if (correlationId) pendingVideos.delete(correlationId)
+11 -21
View File
@@ -54,6 +54,7 @@ let metadata: MemoRecordingMetadata = { note: '', pinned: false, title: '' }
let currentState: MemoRecorderStateName = 'idle' let currentState: MemoRecorderStateName = 'idle'
let currentElapsedMs = 0 let currentElapsedMs = 0
let currentCorrelationId = '' let currentCorrelationId = ''
let recordingDeviceImei = ''
let removeRecorderErrorListener: (() => void) | null = null let removeRecorderErrorListener: (() => void) | null = null
function postRecorderState(state: MemoRecorderStateName, error?: string): void { function postRecorderState(state: MemoRecorderStateName, error?: string): void {
@@ -153,6 +154,7 @@ function resetRecordingData(): void {
pausedStartedAt = 0 pausedStartedAt = 0
totalPausedMs = 0 totalPausedMs = 0
currentElapsedMs = 0 currentElapsedMs = 0
recordingDeviceImei = ''
liveLevels = Array(LIVE_LEVEL_SAMPLES).fill(0.08) liveLevels = Array(LIVE_LEVEL_SAMPLES).fill(0.08)
} }
@@ -189,7 +191,8 @@ function failRecording(error: string, generation = recordingGeneration): void {
} }
async function startRecording(data: Record<string, unknown>): Promise<void> { async function startRecording(data: Record<string, unknown>): Promise<void> {
if (!phone.isOpen) { const deviceImei = phone.device?.imei
if (!phone.isOpen || !deviceImei) {
console.error('[Memos] Cannot start a recording while the phone is closed.') console.error('[Memos] Cannot start a recording while the phone is closed.')
return return
} }
@@ -217,6 +220,7 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
metadata = { note: '', pinned: false, title: '' } metadata = { note: '', pinned: false, title: '' }
updateMetadata(data) updateMetadata(data)
resetRecordingData() resetRecordingData()
recordingDeviceImei = deviceImei
postRecorderState('starting') postRecorderState('starting')
try { try {
const acquiredStream = await navigator.mediaDevices.getUserMedia({ const acquiredStream = await navigator.mediaDevices.getUserMedia({
@@ -340,6 +344,7 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
if (generation !== recordingGeneration) return if (generation !== recordingGeneration) return
const waveform = compressedWaveform() const waveform = compressedWaveform()
const finalMetadata = { ...metadata } const finalMetadata = { ...metadata }
const finalDeviceImei = recordingDeviceImei
const exceededSizeLimit = recordingTooLarge const exceededSizeLimit = recordingTooLarge
cleanupRecorder(false) cleanupRecorder(false)
resetRecordingData() resetRecordingData()
@@ -355,10 +360,12 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
liveLevels = waveform.slice(-LIVE_LEVEL_SAMPLES) liveLevels = waveform.slice(-LIVE_LEVEL_SAMPLES)
const uploadData = { const uploadData = {
correlationId, correlationId,
deviceImei: finalDeviceImei,
durationMs, durationMs,
mimeType, mimeType,
note: finalMetadata.note, note: finalMetadata.note,
pinned: finalMetadata.pinned, pinned: finalMetadata.pinned,
sizeBytes: blob.size,
title: finalMetadata.title, title: finalMetadata.title,
waveform, waveform,
} }
@@ -452,14 +459,6 @@ async function uploadReady(ready: MemoUploadReady): Promise<void> {
pending.requestId = ready.requestId pending.requestId = ready.requestId
const form = new FormData() const form = new FormData()
form.append('file', pending.blob, pending.fileName) form.append('file', pending.blob, pending.fileName)
form.append(
'metadata',
JSON.stringify({
captureToken: ready.captureToken,
purpose: 'memo',
source: 'sky_phone',
}),
)
const controller = new AbortController() const controller = new AbortController()
pending.abortController = controller pending.abortController = controller
const timeout = window.setTimeout( const timeout = window.setTimeout(
@@ -569,18 +568,9 @@ function onMessage(event: MessageEvent): void {
onMounted(() => window.addEventListener('message', onMessage)) onMounted(() => window.addEventListener('message', onMessage))
watch( watch(
() => () => phone.device?.imei ?? null,
[ (imei, previousImei) => {
phone.isOpen, if (previousImei && imei !== previousImei) cancelRecording()
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; z-index: 40;
display: flex; display: flex;
pointer-events: none; pointer-events: none;
font-family: Inter, ui-sans-serif, system-ui, sans-serif; font-family: var(--sky-font-family);
} }
.radio-hud[data-horizontal='left'] { .radio-hud[data-horizontal='left'] {
@@ -677,9 +677,7 @@ onBeforeUnmount(() => {
backdrop-filter: blur(26px) saturate(125%); backdrop-filter: blur(26px) saturate(125%);
-webkit-backdrop-filter: blur(26px) saturate(125%); -webkit-backdrop-filter: blur(26px) saturate(125%);
cursor: pointer; cursor: pointer;
font-family: font-family: var(--sky-font-family);
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
'Segoe UI', sans-serif;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
touch-action: none; touch-action: none;
+6 -1
View File
@@ -1,3 +1,5 @@
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
export type MemorySound = 'flip' | 'match' | 'mismatch' | 'win' export type MemorySound = 'flip' | 'match' | 'mismatch' | 'win'
type Tone = { type Tone = {
@@ -46,7 +48,10 @@ export function playMemorySound(sound: MemorySound, enabled: boolean): void {
oscillator.type = tone.type oscillator.type = tone.type
oscillator.frequency.setValueAtTime(tone.frequency, start) oscillator.frequency.setValueAtTime(tone.frequency, start)
gain.gain.setValueAtTime(0.0001, start) gain.gain.setValueAtTime(0.0001, start)
gain.gain.exponentialRampToValueAtTime(tone.volume, start + 0.012) gain.gain.exponentialRampToValueAtTime(
Math.max(0.0001, tone.volume * getPhoneOutputVolume()),
start + 0.012,
)
gain.gain.exponentialRampToValueAtTime(0.0001, end) gain.gain.exponentialRampToValueAtTime(0.0001, end)
oscillator.connect(gain) oscillator.connect(gain)
gain.connect(audioContext.destination) gain.connect(audioContext.destination)
@@ -1,3 +1,5 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import flagUrl from '@/assets/audio/minesweeper/flag.wav?url' import flagUrl from '@/assets/audio/minesweeper/flag.wav?url'
import clearUrl from '@/assets/audio/minesweeper/clear.wav?url' import clearUrl from '@/assets/audio/minesweeper/clear.wav?url'
import mineUrl from '@/assets/audio/minesweeper/mine.wav?url' import mineUrl from '@/assets/audio/minesweeper/mine.wav?url'
@@ -28,7 +30,7 @@ function getPlayers(sound: MinesweeperSound): HTMLAudioElement[] {
if (existing) return existing if (existing) return existing
const players = Array.from({ length: 3 }, () => { const players = Array.from({ length: 3 }, () => {
const player = new Audio(soundUrls[sound]) const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
player.preload = 'auto' player.preload = 'auto'
player.volume = 0.82 player.volume = 0.82
return player return player
@@ -1,3 +1,5 @@
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
export type NeonDropSound = export type NeonDropSound =
| 'clear' | 'clear'
| 'drop' | 'drop'
@@ -48,7 +50,10 @@ function playSequence(sound: NeonDropSound): void {
oscillator.type = type oscillator.type = type
oscillator.frequency.setValueAtTime(frequency, start + delay) oscillator.frequency.setValueAtTime(frequency, start + delay)
gain.gain.setValueAtTime(0.0001, start + delay) gain.gain.setValueAtTime(0.0001, start + delay)
gain.gain.exponentialRampToValueAtTime(0.12, start + delay + 0.008) gain.gain.exponentialRampToValueAtTime(
Math.max(0.0001, 0.12 * getPhoneOutputVolume()),
start + delay + 0.008,
)
gain.gain.exponentialRampToValueAtTime(0.0001, start + delay + duration) gain.gain.exponentialRampToValueAtTime(0.0001, start + delay + duration)
oscillator.connect(gain) oscillator.connect(gain)
gain.connect(context.destination) gain.connect(context.destination)
@@ -1,3 +1,5 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import gameOverUrl from '@/assets/audio/number-merge/game-over.wav?url' import gameOverUrl from '@/assets/audio/number-merge/game-over.wav?url'
import mergeUrl from '@/assets/audio/number-merge/merge.wav?url' import mergeUrl from '@/assets/audio/number-merge/merge.wav?url'
import moveUrl from '@/assets/audio/number-merge/move.wav?url' import moveUrl from '@/assets/audio/number-merge/move.wav?url'
@@ -18,7 +20,7 @@ function getPlayers(sound: NumberMergeSound): HTMLAudioElement[] {
if (existing) return existing if (existing) return existing
const players = Array.from({ length: 3 }, () => { const players = Array.from({ length: 3 }, () => {
const player = new Audio(soundUrls[sound]) const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
player.preload = 'auto' player.preload = 'auto'
player.volume = 0.82 player.volume = 0.82
return player return player
@@ -1,3 +1,5 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import crashUrl from '@/assets/audio/sky-flappy/crash.wav?url' import crashUrl from '@/assets/audio/sky-flappy/crash.wav?url'
import flapUrl from '@/assets/audio/sky-flappy/flap.wav?url' import flapUrl from '@/assets/audio/sky-flappy/flap.wav?url'
import pointUrl from '@/assets/audio/sky-flappy/point.wav?url' import pointUrl from '@/assets/audio/sky-flappy/point.wav?url'
@@ -11,7 +13,7 @@ export function playSkyFlappySound(sound: SkyFlappySound, enabled: boolean): voi
let players = pools.get(sound) let players = pools.get(sound)
if (!players) { if (!players) {
players = Array.from({ length: 3 }, () => { players = Array.from({ length: 3 }, () => {
const player = new Audio(urls[sound]) const player = registerPhoneMediaElement(new Audio(urls[sound]))
player.preload = 'auto' player.preload = 'auto'
player.volume = 0.84 player.volume = 0.84
return player return player
@@ -1,3 +1,5 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import fallUrl from '@/assets/audio/tower-stack/fall.wav?url' import fallUrl from '@/assets/audio/tower-stack/fall.wav?url'
import hitUrl from '@/assets/audio/tower-stack/hit.wav?url' import hitUrl from '@/assets/audio/tower-stack/hit.wav?url'
import perfectUrl from '@/assets/audio/tower-stack/perfect.wav?url' import perfectUrl from '@/assets/audio/tower-stack/perfect.wav?url'
@@ -18,7 +20,7 @@ function getPlayers(sound: TowerStackSound): HTMLAudioElement[] {
if (existing) return existing if (existing) return existing
const players = Array.from({ length: 3 }, () => { const players = Array.from({ length: 3 }, () => {
const player = new Audio(soundUrls[sound]) const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
player.preload = 'auto' player.preload = 'auto'
player.volume = 0.84 player.volume = 0.84
return player return player
@@ -0,0 +1,103 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const mediaConfig = readFileSync(
new URL('../../sky_phone/config/media.lua', import.meta.url),
'utf8',
)
const manifest = readFileSync(
new URL('../../sky_phone/fxmanifest.lua', import.meta.url),
'utf8',
)
const mediaProviderConfig = readFileSync(
new URL(
'../../sky_phone/source/server/media_provider_config.lua',
import.meta.url,
),
'utf8',
)
const mediaImportAdapter = readFileSync(
new URL(
'../../sky_phone/source/server/media_import/fivemanage.lua',
import.meta.url,
),
'utf8',
)
const mediaServer = readFileSync(
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
'utf8',
)
const memoServer = readFileSync(
new URL('../../sky_phone/source/server/memos.lua', import.meta.url),
'utf8',
)
const mediaCapture = readFileSync(
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
'utf8',
)
const memoRecorder = readFileSync(
new URL('./components/PhoneMemoRecorder.vue', import.meta.url),
'utf8',
)
describe('FiveManage server configuration contract', () => {
it('keeps the provider token in the server-only media config', () => {
expect(mediaConfig).toMatch(/FiveManage\s*=\s*{\s*ApiKey\s*=/)
expect(mediaProviderConfig).toContain(
'return trim_key(Config.Media.FiveManage.ApiKey)',
)
expect(mediaProviderConfig).not.toContain('GetConvar')
})
it('uses one resolver for Camera uploads and FiveManage imports', () => {
expect(
manifest.indexOf("'source/server/media_provider_config.lua'"),
).toBeLessThan(manifest.indexOf("'source/server/media_import.lua'"))
expect(mediaServer).toContain(
'SkyPhoneMediaProviderConfig.FiveManageApiKey()',
)
expect(mediaImportAdapter).toContain(
'SkyPhoneMediaProviderConfig.FiveManageApiKey(website.ApiKey)',
)
})
it('uses the direct FiveManage upload response flow for Camera and voice memos', () => {
expect(mediaConfig).not.toContain('VerificationRetryDelaysMs')
expect(mediaCapture).toContain("form.append('file', blob, fileName)")
expect(mediaCapture).not.toContain("form.append('path'")
expect(mediaCapture).not.toContain("form.append(\n 'metadata'")
expect(memoRecorder).toContain(
"form.append('file', pending.blob, pending.fileName)",
)
expect(memoRecorder).not.toContain("form.append('path'")
expect(memoRecorder).not.toContain("form.append(\n 'metadata'")
expect(mediaServer).toContain(
'Accepting the direct FiveManage upload response',
)
expect(mediaServer).toContain('remote_id = remote_id')
expect(mediaServer).toContain('url = uploaded_url')
expect(mediaServer).not.toContain('"HEAD"')
expect(mediaServer).not.toContain('authenticated upload-path lookup')
})
it('allowlists the FiveManage API and media hosts', () => {
expect(mediaServer).toContain('["api.fivemanage.com"] = true')
expect(mediaServer).toContain('["fmapi.net"] = true')
expect(mediaServer).toContain(
'uploaded_host:lower() ~= "r2.fivemanage.com"',
)
})
it('validates and preserves the recorded memo size before upload', () => {
expect(memoRecorder).toContain('sizeBytes: blob.size')
expect(memoServer).toContain(
'local size_bytes = tonumber(data.sizeBytes)',
)
expect(memoServer).toContain(
'size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes',
)
expect(memoServer).toContain('size_bytes = memo.size_bytes')
expect(mediaServer).toContain('size = state.size_bytes')
})
})
@@ -0,0 +1,21 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const mediaCapture = readFileSync(
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
'utf8',
)
const mediaServer = readFileSync(
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
'utf8',
)
describe('media upload diagnostics contracts', () => {
it('forwards browser upload failure context to the server log', () => {
expect(mediaCapture).toContain("let debugStage = 'provider_request'")
expect(mediaCapture).toContain('debugStatus: debug.status')
expect(mediaServer).toContain('Client-reported upload failure')
expect(mediaServer).toContain('diagnostic_text(data.debugMessage, 240)')
})
})
+37
View File
@@ -0,0 +1,37 @@
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;/)
})
})
+17 -1
View File
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
const readResourceFile = (path: string) => const readResourceFile = (path: string) =>
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8') readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8').replace(/\r\n/g, '\n')
const readFrontendFile = (path: string) => const readFrontendFile = (path: string) =>
readFileSync(new URL(path, import.meta.url), 'utf8') readFileSync(new URL(path, import.meta.url), 'utf8')
@@ -232,6 +232,22 @@ 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', () => { it('keeps a server-selected unique handset as the preferred hotkey device', () => {
const phoneServer = readResourceFile('source/server/phone.lua') const phoneServer = readResourceFile('source/server/phone.lua')
+6
View File
@@ -15,6 +15,12 @@ const developmentRoutes: RouteRecordRaw[] = import.meta.env.DEV
name: 'development-sky-ui', name: 'development-sky-ui',
path: '/development/sky-ui/:demo?', path: '/development/sky-ui/:demo?',
}, },
{
component: () =>
import('@/views/development/PhoneDynamicIslandGallery.vue'),
name: 'development-dynamic-islands',
path: '/development/dynamic-islands',
},
] ]
: [] : []
+41 -6
View File
@@ -64,6 +64,41 @@ describe('app store', () => {
expect(apps.isInstalled('snake')).toBe(false) expect(apps.isInstalled('snake')).toBe(false)
expect(apps.isInstalled('health')).toBe(true) expect(apps.isInstalled('health')).toBe(true)
expect(apps.isInstalled('citywarn')).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('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', () => { it('removes old automatic apps unless the player installed them', () => {
@@ -320,7 +355,7 @@ describe('app store', () => {
const apps = useAppStoreStore() const apps = useAppStoreStore()
apps.hydrate(null) apps.hydrate(null)
mocks.phone.saveDeviceNamespace.mockClear() mocks.phone.saveDeviceNamespace.mockClear()
const sourceIndex = apps.homeLayout.grid.indexOf('phone') const sourceIndex = apps.homeLayout.grid.indexOf('calculator')
expect(sourceIndex).toBeGreaterThanOrEqual(0) expect(sourceIndex).toBeGreaterThanOrEqual(0)
expect( expect(
@@ -329,7 +364,7 @@ describe('app store', () => {
HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE,
]), ]),
).toBe(true) ).toBe(true)
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('phone') expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('calculator')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1) expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
}) })
@@ -386,18 +421,18 @@ describe('app store', () => {
mocks.phone.saveDeviceNamespace.mockClear() mocks.phone.saveDeviceNamespace.mockClear()
const notesIndex = apps.homeLayout.grid.indexOf('notes') const notesIndex = apps.homeLayout.grid.indexOf('notes')
const clockIndex = apps.homeLayout.grid.indexOf('clock') const settingsIndex = apps.homeLayout.grid.indexOf('settings')
const folderId = apps.createHomeFolder( const folderId = apps.createHomeFolder(
'grid', 'grid',
notesIndex, notesIndex,
'grid', 'grid',
clockIndex, settingsIndex,
'Utilities', 'Utilities',
) )
expect(folderId).toBeTruthy() expect(folderId).toBeTruthy()
expect(getHomeFolder(apps.homeLayout, folderId!)?.apps).toEqual([ expect(getHomeFolder(apps.homeLayout, folderId!)?.apps).toEqual([
'clock', 'settings',
'notes', 'notes',
]) ])
const mailIndex = apps.homeLayout.grid.indexOf('mail') const mailIndex = apps.homeLayout.grid.indexOf('mail')
@@ -405,7 +440,7 @@ describe('app store', () => {
apps.moveHomeFolderApp(folderId!, 2, 0) apps.moveHomeFolderApp(folderId!, 2, 0)
apps.renameHomeFolder(folderId!, 'Work') apps.renameHomeFolder(folderId!, 'Work')
expect(getHomeFolder(apps.homeLayout, folderId!)).toMatchObject({ expect(getHomeFolder(apps.homeLayout, folderId!)).toMatchObject({
apps: ['mail', 'notes', 'clock'], apps: ['mail', 'notes', 'settings'],
name: 'Work', name: 'Work',
}) })
+10 -7
View File
@@ -25,6 +25,7 @@ import {
parseHomeLayout, parseHomeLayout,
reflowHomeGridForWidgetChange, reflowHomeGridForWidgetChange,
renameHomeFolder, renameHomeFolder,
removeDockGridDuplicates,
removeHomeApp, removeHomeApp,
restoreHomeApp, restoreHomeApp,
type HomeArea, type HomeArea,
@@ -45,7 +46,7 @@ const pendingInstallations = new WeakMap<
>() >()
function getDefaultGridIds(): LaunchablePhoneAppId[] { function getDefaultGridIds(): LaunchablePhoneAppId[] {
return [...PHONE_APPS] return PHONE_APPS.filter((app) => app.dockOrder === null)
.sort((a, b) => a.gridOrder - b.gridOrder) .sort((a, b) => a.gridOrder - b.gridOrder)
.map((app) => app.id) .map((app) => app.id)
} }
@@ -263,12 +264,16 @@ export const useAppStoreStore = defineStore('app-store', {
getDefaultGridIds(), getDefaultGridIds(),
getDefaultDockIds(), getDefaultDockIds(),
) )
this.homeLayout = parseHomeLayout( const parsedHomeLayout = parseHomeLayout(
data?.homeLayout, data?.homeLayout,
defaults, defaults,
installedIds, installedIds,
false, false,
) )
const normalizedHomeLayout = removeDockGridDuplicates(parsedHomeLayout)
const removedDockGridDuplicates =
normalizedHomeLayout !== parsedHomeLayout
this.homeLayout = normalizedHomeLayout
const protectedHiddenAppIds = const protectedHiddenAppIds =
this.homeLayout.hidden.filter(isProtectedHomeApp) this.homeLayout.hidden.filter(isProtectedHomeApp)
for (const appId of protectedHiddenAppIds) { for (const appId of protectedHiddenAppIds) {
@@ -292,6 +297,7 @@ export const useAppStoreStore = defineStore('app-store', {
this.hydrated = true this.hydrated = true
if ( if (
protectedHiddenAppIds.length || protectedHiddenAppIds.length ||
removedDockGridDuplicates ||
removedLegacyDefaults || removedLegacyDefaults ||
layoutVersion === 2 || layoutVersion === 2 ||
layoutVersion === 3 || layoutVersion === 3 ||
@@ -320,11 +326,8 @@ export const useAppStoreStore = defineStore('app-store', {
getDefaultDockIds(), getDefaultDockIds(),
) )
const previous = JSON.stringify(this.homeLayout) const previous = JSON.stringify(this.homeLayout)
this.homeLayout = parseHomeLayout( this.homeLayout = removeDockGridDuplicates(
this.homeLayout, parseHomeLayout(this.homeLayout, defaults, installedIds, false),
defaults,
installedIds,
false,
) )
for (const appId of [...this.homeLayout.hidden]) { for (const appId of [...this.homeLayout.hidden]) {
+16 -4
View File
@@ -7,6 +7,11 @@ import type {
MusicTrack, MusicTrack,
} from '@/types/music' } from '@/types/music'
import { nuiCall } from '@/utils/nui' import { nuiCall } from '@/utils/nui'
import {
getPhoneOutputVolume,
registerPhoneMediaElement,
subscribePhoneOutputVolume,
} from '@/utils/phoneAudio'
export type YouTubePlayer = { export type YouTubePlayer = {
destroy: () => void destroy: () => void
@@ -48,7 +53,7 @@ declare global {
} }
} }
const audio = new Audio() const audio = registerPhoneMediaElement(new Audio())
audio.preload = 'auto' audio.preload = 'auto'
const YOUTUBE_API_TIMEOUT_MS = 12000 const YOUTUBE_API_TIMEOUT_MS = 12000
let audioBound = false let audioBound = false
@@ -59,6 +64,11 @@ let youtubeApiPromise: Promise<YouTubeApi> | null = null
let youtubePlayer: YouTubePlayer | null = null let youtubePlayer: YouTubePlayer | null = null
let youtubeProgressTimer: number | null = null let youtubeProgressTimer: number | null = null
subscribePhoneOutputVolume((volume) => {
const localVolume = useMusicStore().volume
youtubePlayer?.setVolume(localVolume * volume * 100)
})
export function musicTrackKey( export function musicTrackKey(
track: Pick<MusicTrack, 'id' | 'source'>, track: Pick<MusicTrack, 'id' | 'source'>,
): string { ): string {
@@ -276,7 +286,7 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
const api = await loadYouTubeApi() const api = await loadYouTubeApi()
const store = useMusicStore() const store = useMusicStore()
if (youtubePlayer) { if (youtubePlayer) {
youtubePlayer.setVolume(store.volume * 100) youtubePlayer.setVolume(store.volume * getPhoneOutputVolume() * 100)
youtubePlayer.loadVideoById(videoId) youtubePlayer.loadVideoById(videoId)
youtubePlayer.playVideo() youtubePlayer.playVideo()
startYoutubeProgress() startYoutubeProgress()
@@ -319,7 +329,9 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
}, },
onReady: (event) => { onReady: (event) => {
youtubePlayer = event.target youtubePlayer = event.target
event.target.setVolume(useMusicStore().volume * 100) event.target.setVolume(
useMusicStore().volume * getPhoneOutputVolume() * 100,
)
event.target.playVideo() event.target.playVideo()
startYoutubeProgress() startYoutubeProgress()
resolve() resolve()
@@ -554,7 +566,7 @@ export const useMusicStore = defineStore('music', {
setVolume(value: number): void { setVolume(value: number): void {
this.volume = Math.max(0, Math.min(1, value)) this.volume = Math.max(0, Math.min(1, value))
audio.volume = this.volume audio.volume = this.volume
youtubePlayer?.setVolume(this.volume * 100) youtubePlayer?.setVolume(this.volume * getPhoneOutputVolume() * 100)
}, },
stop(): void { stop(): void {
stopActiveMedia() stopActiveMedia()
+5
View File
@@ -4012,6 +4012,11 @@ const defaultLocales: LocaleTree = {
invalid_media_type: 'The uploaded media type is invalid.', invalid_media_type: 'The uploaded media type is invalid.',
invalid_upload: 'The upload could not be verified.', invalid_upload: 'The upload could not be verified.',
invalid_upload_token: 'The upload session is no longer valid.', invalid_upload_token: 'The upload session is no longer valid.',
media_provider_failed: 'The camera upload service is unavailable.',
media_provider_rate_limited:
'The camera upload service is busy. Try again shortly.',
media_provider_unauthorized:
'The configured FiveManage API key was rejected.',
missing_config: 'Camera uploads are not configured.', missing_config: 'Camera uploads are not configured.',
microphone_unavailable: microphone_unavailable:
'Allow microphone access or mute the microphone before recording.', 'Allow microphone access or mute the microphone before recording.',
+7
View File
@@ -0,0 +1,7 @@
export type DynamicIslandActivity =
| 'call'
| 'incoming-call'
| 'music'
| 'recording'
| 'stopwatch'
| 'timer'
-1
View File
@@ -70,7 +70,6 @@ export type MediaImportResult = {
} }
export type UploadReady = { export type UploadReady = {
captureToken: string
correlationId: string correlationId: string
mediaType: MediaType mediaType: MediaType
photo?: { photo?: {
-1
View File
@@ -39,7 +39,6 @@ export type MemoRecordingMetadata = {
export type MemoUploadReady = { export type MemoUploadReady = {
requestId: string requestId: string
correlationId: string correlationId: string
captureToken: string
presignedUrl: string presignedUrl: string
uploadTimeoutMs?: number uploadTimeoutMs?: number
} }
@@ -0,0 +1,59 @@
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([])
})
})
+2 -3
View File
@@ -525,9 +525,8 @@
height: var(--sky-widget-label-height); height: var(--sky-widget-label-height);
overflow: hidden; overflow: hidden;
color: var(--sky-widget-label-color, #fff); color: var(--sky-widget-label-color, #fff);
font-family: font-family: var(--sky-font-family);
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif; font-size: var(--sky-home-label-font-size);
font-size: 11.5px;
font-weight: 500; font-weight: 500;
letter-spacing: -0.15px; letter-spacing: -0.15px;
line-height: var(--sky-widget-label-height); line-height: var(--sky-widget-label-height);
+20 -4
View File
@@ -1,3 +1,19 @@
@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 { :root {
--sky-device-pixel-ratio: 1; --sky-device-pixel-ratio: 1;
--sky-hairline-scale: 1; --sky-hairline-scale: 1;
@@ -26,9 +42,7 @@
--sky-font-title: 17px; --sky-font-title: 17px;
--sky-font-medium-title: 24px; --sky-font-medium-title: 24px;
--sky-font-large-title: 34px; --sky-font-large-title: 34px;
--sky-font-family: --sky-font-family: 'Inter', Arial, sans-serif;
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF UI Text',
'Helvetica Neue', Helvetica, Arial, sans-serif;
--sky-transition-fast: 100ms; --sky-transition-fast: 100ms;
--sky-transition-normal: 200ms; --sky-transition-normal: 200ms;
--sky-ease-out: cubic-bezier(0.22, 1, 0.36, 1); --sky-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
@@ -55,8 +69,10 @@
--sky-shadow-thumb: --sky-shadow-thumb:
0 0.5px 4px rgba(0, 0, 0, 0.12), 0 6px 13px rgba(0, 0, 0, 0.12); 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-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-gap: 5px;
--sky-widget-label-height: 15px; --sky-widget-label-height: var(--sky-home-label-height);
--sky-widget-radius-small: 23px; --sky-widget-radius-small: 23px;
--sky-widget-radius-medium: 25px; --sky-widget-radius-medium: 25px;
--sky-widget-radius-large: 28px; --sky-widget-radius-large: 28px;
+35
View File
@@ -21,6 +21,7 @@ import {
moveHomeFolderApp, moveHomeFolderApp,
parseHomeLayout, parseHomeLayout,
reflowHomeGridForWidgetChange, reflowHomeGridForWidgetChange,
removeDockGridDuplicates,
removeHomeApp, removeHomeApp,
renameHomeFolder, renameHomeFolder,
restoreHomeApp, restoreHomeApp,
@@ -78,6 +79,40 @@ describe('home layout', () => {
expect(layout.version).toBe(HOME_LAYOUT_VERSION) 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', () => { it('migrates compact persisted arrays and appends newly installed apps', () => {
const layout = parseHomeLayout( const layout = parseHomeLayout(
{ {
+31
View File
@@ -505,6 +505,37 @@ 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( export function parseHomeLayout(
value: unknown, value: unknown,
defaults: HomeLayout, defaults: HomeLayout,
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { runInNewContext } from 'node:vm'
import type { ExternalPhoneAppDefinition } from '@/types/apps' import type { ExternalPhoneAppDefinition } from '@/types/apps'
import { import {
@@ -139,6 +140,79 @@ describe('LB Phone app bridge', () => {
expect(() => new Function(runtime)).not.toThrow() 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', () => { it('persists isolated LB localStorage snapshots without app changes', () => {
const values = new Map<string, string>() const values = new Map<string, string>()
const storage = { const storage = {
+28 -1
View File
@@ -130,6 +130,25 @@ function applySettings(nextSettings) {
if (document.body) document.body.dataset.theme = theme; 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.resourceName = config.resourceName;
globalThis.appName = config.appName; globalThis.appName = config.appName;
globalThis.components = globalThis.components ?? {}; globalThis.components = globalThis.components ?? {};
@@ -189,6 +208,11 @@ globalThis.getSettings = async () => globalThis.settings;
globalThis.addEventListener('message', (event) => { globalThis.addEventListener('message', (event) => {
const message = event.data; const message = event.data;
if (message === 'componentsLoaded') {
globalThis.componentsLoaded = true;
prepareDocument();
return;
}
if (!message || typeof message !== 'object') return; if (!message || typeof message !== 'object') return;
if (message.type === 'sky-phone:lb-settings') { if (message.type === 'sky-phone:lb-settings') {
@@ -210,7 +234,10 @@ globalThis.addEventListener('message', (event) => {
}); });
applySettings(config.settings); applySettings(config.settings);
document.addEventListener('DOMContentLoaded', () => applySettings(globalThis.settings), { once: true }); document.addEventListener('DOMContentLoaded', () => {
prepareDocument();
applySettings(globalThis.settings);
}, { once: true });
` `
function escapeAttribute(value: string): string { function escapeAttribute(value: string): string {
+7
View File
@@ -158,6 +158,13 @@ describe('media utilities', () => {
expect(mediaErrorKey('profile_photo_required')).toBe( expect(mediaErrorKey('profile_photo_required')).toBe(
'profile_photo_required', 'profile_photo_required',
) )
expect(mediaErrorKey('media_provider_failed')).toBe('media_provider_failed')
expect(mediaErrorKey('media_provider_rate_limited')).toBe(
'media_provider_rate_limited',
)
expect(mediaErrorKey('media_provider_unauthorized')).toBe(
'media_provider_unauthorized',
)
expect(mediaErrorKey('private_provider_error')).toBe('request_failed') expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
}) })
}) })
+3
View File
@@ -95,6 +95,9 @@ export function mediaErrorKey(error?: string): string {
'invalid_import_url', 'invalid_import_url',
'invalid_upload', 'invalid_upload',
'invalid_upload_token', 'invalid_upload_token',
'media_provider_failed',
'media_provider_rate_limited',
'media_provider_unauthorized',
'missing_config', 'missing_config',
'import_media_not_allowed', 'import_media_not_allowed',
'import_media_too_large', 'import_media_too_large',
+96
View File
@@ -0,0 +1,96 @@
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)
}
@@ -0,0 +1,174 @@
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()
})
})
@@ -0,0 +1,73 @@
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,4 +140,56 @@ describe('springboard widget drag', () => {
expect(delta.x).toBeCloseTo(100) expect(delta.x).toBeCloseTo(100)
expect(delta.y).toBeCloseTo(200) 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)
})
}) })
+5 -1
View File
@@ -1,3 +1,5 @@
import { readPhoneViewportGeometry } from '@/utils/phoneViewportGeometry'
export type PageTurnDirection = -1 | 0 | 1 export type PageTurnDirection = -1 | 0 | 1
export type SpringboardEdgeTurn = { export type SpringboardEdgeTurn = {
@@ -39,7 +41,9 @@ export function readSpringboardDragMetrics(
'.springboard-page, .home-folder-panel', '.springboard-page, .home-folder-panel',
) )
if (!surface) return null if (!surface) return null
const bounds = surface.getBoundingClientRect() const bounds =
readPhoneViewportGeometry(surface)?.rect(surface) ??
surface.getBoundingClientRect()
return { return {
layoutHeight: surface.offsetHeight, layoutHeight: surface.offsetHeight,
layoutWidth: surface.offsetWidth, layoutWidth: surface.offsetWidth,
+2 -1
View File
@@ -32,7 +32,7 @@ describe('phone tones', () => {
}> = [] }> = []
vi.stubGlobal( vi.stubGlobal(
'Audio', 'Audio',
class { class extends EventTarget {
currentTime = 7 currentTime = 7
loop = false loop = false
pause = pause pause = pause
@@ -42,6 +42,7 @@ describe('phone tones', () => {
volume = 0 volume = 0
constructor(src: string) { constructor(src: string) {
super()
this.src = src this.src = src
players.push(this) players.push(this)
} }
+3 -2
View File
@@ -1,3 +1,4 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import type { AlarmSoundId } from '@/utils/alarms' import type { AlarmSoundId } from '@/utils/alarms'
import type { NotificationSoundId } from '@/utils/preferences' import type { NotificationSoundId } from '@/utils/preferences'
@@ -495,8 +496,8 @@ export function playPhoneVibration(
kind: PhoneVibrationKind, kind: PhoneVibrationKind,
loop: boolean, loop: boolean,
): () => void { ): () => void {
const player = new Audio( const player = registerPhoneMediaElement(
`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`, new Audio(`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`),
) )
let stopped = false let stopped = false
player.loop = loop player.loop = loop
@@ -19,11 +19,31 @@ const mainCss = readFileSync(
new URL('../assets/main.css', import.meta.url), new URL('../assets/main.css', import.meta.url),
'utf8', '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( const builtInWallpaperCss = mainCss.slice(
mainCss.indexOf('.wallpaper--midnight'), mainCss.indexOf('.wallpaper--midnight'),
mainCss.indexOf('.wallpaper--custom'), mainCss.indexOf('.wallpaper--custom'),
) )
describe('Springboard page swipe contract', () => { 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', () => { it('keeps the built-in wallpapers visually restrained', () => {
expect(builtInWallpaperCss).not.toMatch(/(?:conic|repeating-\w+)-gradient/) expect(builtInWallpaperCss).not.toMatch(/(?:conic|repeating-\w+)-gradient/)
expect(builtInWallpaperCss.match(/radial-gradient/g)).toHaveLength(12) expect(builtInWallpaperCss.match(/radial-gradient/g)).toHaveLength(12)
@@ -94,36 +114,99 @@ describe('Springboard page swipe contract', () => {
expect(folderIconSource).toContain(':style="dragPointerStyle"') expect(folderIconSource).toContain(':style="dragPointerStyle"')
}) })
it('uses the same scale-aware in-phone drag path as widgets', () => { it('renders the home drag visual through the unzoomed phone portal', () => {
expect(viewSource).not.toContain('source.cloneNode(true)') expect(viewSource).toContain('source.cloneNode(true)')
expect(viewSource).not.toContain('<Teleport to="body">')
expect(viewSource).not.toContain(':external-drag-visual')
expect(viewSource).toContain( expect(viewSource).toContain(
"'springboard--home-dragging': draggingHomeApp !== null", '<Teleport defer to="#phone-home-drag-portal">',
) )
expect(viewSource).toContain('draggedElement?.getBoundingClientRect()') expect(viewSource).toContain(
expect(appIconSource).not.toContain('externalDragVisual') '<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>',
expect(folderIconSource).not.toContain('externalDragVisual') )
expect(mainCss).not.toContain('.home-drag-layer') expect(viewSource).toContain('readPhoneViewportGeometry')
expect(mainCss).not.toContain('.home-drag-ghost') expect(viewSource).toMatch(/const sourceBounds = \w+\.rect\(source\)/)
expect(mainCss).not.toContain('.app-icon-item--drag-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(mainCss).toMatch( expect(mainCss).toMatch(
/\.springboard--home-dragging \.springboard-page--apps\s*\{[^}]*overflow:\s*visible;/s, /\.phone-home-drag-portal\s*\{[^}]*pointer-events:\s*none;/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\s*\{[\s\S]*?overflow:\s*hidden;/)
expect(mainCss).toMatch( expect(mainCss).toMatch(
/\.springboard-page--apps\s*\{[^}]*overflow:\s*hidden;/s, /\.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', () => { 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-page="page.page"')
expect(viewSource).toContain(':data-home-target-offset="cell.targetOffset"') expect(viewSource).toContain(':data-home-target-offset="cell.targetOffset"')
expect(viewSource).toContain('nearestGridDropTarget') expect(viewSource).toContain('nearestGridDropTarget')
expect(viewSource).toContain('moveHomeAppToGridPage') expect(viewSource).toContain('moveHomeAppToGridPage')
expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'") expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'")
expect(viewSource).toContain('event.clientX < pageBounds.left') expect(viewSource).toContain('event.clientX < springboardBounds.left')
expect(viewSource).toContain('event.clientY > pageBounds.bottom') 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('nearestSpringboardRectIndex') expect(viewSource).toContain('nearestSpringboardRectIndex')
expect(viewSource).toContain("queueEdgePageTurn(lastHomePointer, 'app')") expect(viewSource).toContain("queueEdgePageTurn(lastHomePointer, 'app')")
expect(viewSource).toContain("edgePageLocked = dragType === 'widget'") expect(viewSource).toContain("edgePageLocked = dragType === 'widget'")
+273 -35
View File
@@ -41,6 +41,11 @@ import {
type HomeItem, type HomeItem,
} from '@/utils/homeLayout' } from '@/utils/homeLayout'
import type { ReorderDirection } from '@/utils/keyboard' import type { ReorderDirection } from '@/utils/keyboard'
import {
readPhoneViewportGeometry,
type PhoneViewportGeometry,
type PhoneViewportRect,
} from '@/utils/phoneViewportGeometry'
import { layoutSpringboardHomePages } from '@/utils/springboardLayout' import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
import { import {
maximumRenderedWidgetPage, maximumRenderedWidgetPage,
@@ -106,6 +111,8 @@ const draggingHomeApp = ref<{
area: HomeArea area: HomeArea
index: number index: number
} | null>(null) } | null>(null)
const homeDragLayer = ref<HTMLElement | null>(null)
const homeDragVisualActive = ref(false)
const temporaryHomePage = ref<number | null>(null) const temporaryHomePage = ref<number | null>(null)
const openedFolderId = ref<string | null>(null) const openedFolderId = ref<string | null>(null)
const folderDraggingOutside = ref(false) const folderDraggingOutside = ref(false)
@@ -125,6 +132,11 @@ let edgePageDirection = 0
let edgePageLocked = false let edgePageLocked = false
let folderHoverTimer: number | undefined let folderHoverTimer: number | undefined
let lastHomePointer: { clientX: number; clientY: number } | null = null 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(() => const installedApps = computed(() =>
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)), PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
@@ -453,7 +465,7 @@ function startWidgetDrag(id: string, event: PointerEvent): void {
).find((candidate) => candidate.dataset.widgetId === id) ).find((candidate) => candidate.dataset.widgetId === id)
if (!widget) return if (!widget) return
pageTransitioning.value = false pageTransitioning.value = false
const bounds = widget.getBoundingClientRect() const bounds = homeDragViewportRect(widget)
draggingWidgetId.value = id draggingWidgetId.value = id
widgetDragGrip.value = { widgetDragGrip.value = {
x: event.clientX - bounds.left, x: event.clientX - bounds.left,
@@ -474,7 +486,7 @@ function updateWidgetDragPreview(event: {
) )
const pageElement = grid?.closest<HTMLElement>('.springboard-page') const pageElement = grid?.closest<HTMLElement>('.springboard-page')
if (!grid || !pageElement) return if (!grid || !pageElement) return
const renderedGridBounds = grid.getBoundingClientRect() const renderedGridBounds = homeDragViewportRect(grid)
const gridStyle = getComputedStyle(grid) const gridStyle = getComputedStyle(grid)
const scaleX = const scaleX =
grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1 grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1
@@ -529,7 +541,7 @@ function clearTemporaryHomePage(keepCurrentPage = false): void {
function resolveHomeEdgeTurn(event: { clientX: number }) { function resolveHomeEdgeTurn(event: { clientX: number }) {
const springboard = document.querySelector<HTMLElement>('.springboard') const springboard = document.querySelector<HTMLElement>('.springboard')
if (!springboard) return null if (!springboard) return null
const bounds = springboard.getBoundingClientRect() const bounds = homeDragViewportRect(springboard)
const renderedLastPage = appPages.value.length const renderedLastPage = appPages.value.length
return resolveSpringboardHomeEdgeTurn( return resolveSpringboardHomeEdgeTurn(
event.clientX, event.clientX,
@@ -548,7 +560,7 @@ function queueEdgePageTurn(
): void { ): void {
const springboard = document.querySelector<HTMLElement>('.springboard') const springboard = document.querySelector<HTMLElement>('.springboard')
if (!springboard) return if (!springboard) return
const bounds = springboard.getBoundingClientRect() const bounds = homeDragViewportRect(springboard)
const turn = const turn =
dragType === 'app' dragType === 'app'
? resolveHomeEdgeTurn(event) ? resolveHomeEdgeTurn(event)
@@ -733,10 +745,166 @@ async function saveWidgetConfig(
if (configured) changePage(configured.page) if (configured) changePage(configured.page)
} }
function startHomeDrag(area: HomeArea, index: number): void { 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 {
clearTemporaryHomePage() clearTemporaryHomePage()
lastHomePointer = null lastHomePointer = null
draggingHomeApp.value = { area, index } draggingHomeApp.value = { area, index }
createHomeDragGhost(event)
} }
function clearFolderHover(): void { function clearFolderHover(): void {
@@ -754,10 +922,40 @@ function queueFolderHover(event: PointerEvent): void {
return return
} }
const targetElement = document const targetElement = Array.from(
.elementsFromPoint(event.clientX, event.clientY) document.querySelectorAll<HTMLElement>('[data-home-area][data-home-index]'),
.map((element) => element.closest<HTMLElement>('[data-home-index]')) )
.find((element) => element && !element.closest('.app-icon-item--dragging')) .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 area = targetElement?.dataset.homeArea as HomeArea | undefined const area = targetElement?.dataset.homeArea as HomeArea | undefined
const targetIndex = Number(targetElement?.dataset.homeIndex) const targetIndex = Number(targetElement?.dataset.homeIndex)
if ( if (
@@ -822,6 +1020,7 @@ function queueFolderHover(event: PointerEvent): void {
if (folderId) { if (folderId) {
draggingHomeApp.value = null draggingHomeApp.value = null
lastHomePointer = null lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage() clearTemporaryHomePage()
openedFolderId.value = folderId openedFolderId.value = folderId
} }
@@ -832,6 +1031,7 @@ function queueFolderHover(event: PointerEvent): void {
function moveHomeDrag(event: PointerEvent): void { function moveHomeDrag(event: PointerEvent): void {
if (!draggingHomeApp.value) return if (!draggingHomeApp.value) return
lastHomePointer = { clientX: event.clientX, clientY: event.clientY } lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
updateHomeDragGhost(event)
if (resolveHomeEdgeTurn(event)) { if (resolveHomeEdgeTurn(event)) {
clearFolderHover() clearFolderHover()
queueEdgePageTurn(event, 'app') queueEdgePageTurn(event, 'app')
@@ -845,7 +1045,7 @@ async function animateHomeItemDrop(
item: HomeItem, item: HomeItem,
area: HomeArea, area: HomeArea,
index: number, index: number,
from: DOMRect, from: PhoneViewportRect,
): Promise<void> { ): Promise<void> {
await nextTick() await nextTick()
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
@@ -861,7 +1061,10 @@ async function animateHomeItemDrop(
) )
if (!appElement) return if (!appElement) return
const target = appElement.getBoundingClientRect() const target = homeDragViewportRect(
appElement,
readPhoneViewportGeometry(appElement),
)
const { x: offsetX, y: offsetY } = springboardViewportDeltaToLocal( const { x: offsetX, y: offsetY } = springboardViewportDeltaToLocal(
from.left - target.left, from.left - target.left,
from.top - target.top, from.top - target.top,
@@ -924,13 +1127,16 @@ function nearestGridDropTarget(event: {
`.springboard-page--apps[data-home-page="${page}"] .app-grid`, `.springboard-page--apps[data-home-page="${page}"] .app-grid`,
) )
const pageElement = grid?.closest<HTMLElement>('.springboard-page') const pageElement = grid?.closest<HTMLElement>('.springboard-page')
if (!grid || !pageElement) return null const springboard = grid?.closest<HTMLElement>('.springboard')
const pageBounds = pageElement.getBoundingClientRect() if (!grid || !pageElement || !springboard) return null
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(springboard)
const pageBounds = homeDragViewportRect(pageElement, geometry)
const springboardBounds = homeDragViewportRect(springboard, geometry)
if ( if (
event.clientX < pageBounds.left || event.clientX < springboardBounds.left ||
event.clientX > pageBounds.right || event.clientX > springboardBounds.right ||
event.clientY < pageBounds.top || event.clientY < springboardBounds.top ||
event.clientY > pageBounds.bottom event.clientY > springboardBounds.bottom
) { ) {
return null return null
} }
@@ -940,7 +1146,15 @@ function nearestGridDropTarget(event: {
const closestIndex = nearestSpringboardRectIndex( const closestIndex = nearestSpringboardRectIndex(
event.clientX, event.clientX,
event.clientY, event.clientY,
slots.map((slot) => slot.getBoundingClientRect()), 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,
}
}),
) )
const closest = closestIndex === null ? null : slots[closestIndex] const closest = closestIndex === null ? null : slots[closestIndex]
const targetOffset = Number(closest?.dataset.homeTargetOffset) const targetOffset = Number(closest?.dataset.homeTargetOffset)
@@ -953,7 +1167,8 @@ function nearestDockDropTarget(event: {
}): HTMLElement | null { }): HTMLElement | null {
const dock = document.querySelector<HTMLElement>('.app-dock') const dock = document.querySelector<HTMLElement>('.app-dock')
if (!dock) return null if (!dock) return null
const dockBounds = dock.getBoundingClientRect() const geometry = homeDragGeometry ?? readPhoneViewportGeometry(dock)
const dockBounds = homeDragViewportRect(dock, geometry)
if ( if (
event.clientX < dockBounds.left || event.clientX < dockBounds.left ||
event.clientX > dockBounds.right || event.clientX > dockBounds.right ||
@@ -970,7 +1185,7 @@ function nearestDockDropTarget(event: {
const closestIndex = nearestSpringboardRectIndex( const closestIndex = nearestSpringboardRectIndex(
event.clientX, event.clientX,
event.clientY, event.clientY,
slots.map((slot) => slot.getBoundingClientRect()), slots.map((slot) => homeDragViewportRect(slot, geometry)),
) )
return closestIndex === null ? null : slots[closestIndex] return closestIndex === null ? null : slots[closestIndex]
} }
@@ -979,12 +1194,14 @@ function finishHomeDrag(event: PointerEvent): void {
clearEdgePageTurn() clearEdgePageTurn()
clearFolderHover() clearFolderHover()
const dragged = draggingHomeApp.value const dragged = draggingHomeApp.value
if (!dragged) return if (!dragged) {
clearHomeDragGhost()
return
}
updateHomeDragGhost(event)
const draggedItem = appStore.homeLayout[dragged.area][dragged.index] const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
const draggedElement = document.querySelector<HTMLElement>( const dropGhost = homeDragGhost
`[data-home-area="${dragged.area}"][data-home-index="${dragged.index}"]`, const dropOrigin = homeDragPreviewBounds ? { ...homeDragPreviewBounds } : null
)
const dropOrigin = draggedElement?.getBoundingClientRect()
const dockTarget = nearestDockDropTarget(event) const dockTarget = nearestDockDropTarget(event)
const gridTarget = dockTarget ? null : nearestGridDropTarget(event) const gridTarget = dockTarget ? null : nearestGridDropTarget(event)
let dropArea = dragged.area let dropArea = dragged.area
@@ -1022,7 +1239,14 @@ function finishHomeDrag(event: PointerEvent): void {
lastHomePointer = null lastHomePointer = null
clearTemporaryHomePage(moved && gridTarget !== null) clearTemporaryHomePage(moved && gridTarget !== null)
if (moved && draggedItem && dropOrigin) { if (moved && draggedItem && dropOrigin) {
void animateHomeItemDrop(draggedItem, dropArea, dropIndex, dropOrigin) void animateHomeItemDrop(
draggedItem,
dropArea,
dropIndex,
dropOrigin,
).finally(() => clearHomeDragGhost(dropGhost))
} else {
clearHomeDragGhost()
} }
} }
@@ -1031,6 +1255,7 @@ function stopHomeDrag(): void {
clearFolderHover() clearFolderHover()
draggingHomeApp.value = null draggingHomeApp.value = null
lastHomePointer = null lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage() clearTemporaryHomePage()
} }
@@ -1043,7 +1268,9 @@ function reorderHomeApp(
const appElement = document.querySelector<HTMLElement>( const appElement = document.querySelector<HTMLElement>(
`[data-home-area="${area}"][data-home-index="${sourceIndex}"]`, `[data-home-area="${area}"][data-home-index="${sourceIndex}"]`,
) )
const moveOrigin = appElement?.getBoundingClientRect() const moveOrigin = appElement
? homeDragViewportRect(appElement, readPhoneViewportGeometry(appElement))
: null
const targetIndex = homeKeyboardTarget( const targetIndex = homeKeyboardTarget(
appStore.homeLayout, appStore.homeLayout,
area, area,
@@ -1131,7 +1358,10 @@ function extractOpenedFolderApp(
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`, `.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
) )
?.closest<HTMLElement>('.app-icon-item') ?.closest<HTMLElement>('.app-icon-item')
const sourceBounds = sourceElement?.getBoundingClientRect() const geometry = readPhoneViewportGeometry(sourceElement ?? null)
const sourceBounds = sourceElement
? homeDragViewportRect(sourceElement, geometry)
: null
const candidates = Array.from( const candidates = Array.from(
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'), document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
).filter((element) => { ).filter((element) => {
@@ -1145,8 +1375,8 @@ function extractOpenedFolderApp(
}) })
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => { const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
if (!closest) return candidate if (!closest) return candidate
const bounds = candidate.getBoundingClientRect() const bounds = homeDragViewportRect(candidate, geometry)
const closestBounds = closest.getBoundingClientRect() const closestBounds = homeDragViewportRect(closest, geometry)
const distance = Math.hypot( const distance = Math.hypot(
event.clientX - (bounds.left + bounds.width / 2), event.clientX - (bounds.left + bounds.width / 2),
event.clientY - (bounds.top + bounds.height / 2), event.clientY - (bounds.top + bounds.height / 2),
@@ -1212,6 +1442,7 @@ onBeforeUnmount(() => {
pendingWidgetPointer = null pendingWidgetPointer = null
lastWidgetPointer = null lastWidgetPointer = null
lastHomePointer = null lastHomePointer = null
clearHomeDragGhost()
temporaryHomePage.value = null temporaryHomePage.value = null
draggingHomeApp.value = null draggingHomeApp.value = null
draggingWidgetId.value = null draggingWidgetId.value = null
@@ -1227,7 +1458,6 @@ onBeforeUnmount(() => {
'springboard--dragging': dragging, 'springboard--dragging': dragging,
'springboard--editing': editMode, 'springboard--editing': editMode,
'springboard--folder-open': folderOverlayVisible, 'springboard--folder-open': folderOverlayVisible,
'springboard--home-dragging': draggingHomeApp !== null,
'springboard--widget-dragging': draggingWidgetId !== null, 'springboard--widget-dragging': draggingWidgetId !== null,
}, },
]" ]"
@@ -1303,6 +1533,7 @@ onBeforeUnmount(() => {
:data-home-index="cell.sourceIndex" :data-home-index="cell.sourceIndex"
:data-home-target-offset="cell.targetOffset" :data-home-target-offset="cell.targetOffset"
:edit-mode="editMode" :edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:class="{ :class="{
'home-item--folder-hover': 'home-item--folder-hover':
folderHoverTarget === `grid:${cell.sourceIndex}`, folderHoverTarget === `grid:${cell.sourceIndex}`,
@@ -1310,7 +1541,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag" @dragcancel="stopHomeDrag"
@dragend="finishHomeDrag" @dragend="finishHomeDrag"
@dragmove="moveHomeDrag" @dragmove="moveHomeDrag"
@dragstart="startHomeDrag('grid', cell.sourceIndex)" @dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
@edit="enterEditMode" @edit="enterEditMode"
@remove="removeHomeApp(cell.app.id)" @remove="removeHomeApp(cell.app.id)"
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)" @reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
@@ -1325,6 +1556,7 @@ onBeforeUnmount(() => {
:data-home-index="cell.sourceIndex" :data-home-index="cell.sourceIndex"
:data-home-target-offset="cell.targetOffset" :data-home-target-offset="cell.targetOffset"
:edit-mode="editMode" :edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:folder="cell.folder" :folder="cell.folder"
:class="{ :class="{
'home-item--folder-hover': 'home-item--folder-hover':
@@ -1333,7 +1565,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag" @dragcancel="stopHomeDrag"
@dragend="finishHomeDrag" @dragend="finishHomeDrag"
@dragmove="moveHomeDrag" @dragmove="moveHomeDrag"
@dragstart="startHomeDrag('grid', cell.sourceIndex)" @dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
@edit="enterEditMode" @edit="enterEditMode"
@open="openFolder(cell.folder.id)" @open="openFolder(cell.folder.id)"
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)" @reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
@@ -1484,6 +1716,10 @@ onBeforeUnmount(() => {
</section> </section>
</div> </div>
<Teleport defer to="#phone-home-drag-portal">
<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>
</Teleport>
<Transition name="edit-done"> <Transition name="edit-done">
<k-glass <k-glass
v-if="editMode && isEditablePage && !folderOverlayVisible" v-if="editMode && isEditablePage && !folderOverlayVisible"
@@ -1530,6 +1766,7 @@ onBeforeUnmount(() => {
:data-home-item-key="`app-${slot.app.id}`" :data-home-item-key="`app-${slot.app.id}`"
:data-home-index="slot.index" :data-home-index="slot.index"
:edit-mode="editMode" :edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:show-label="false" :show-label="false"
:class="{ :class="{
'home-item--folder-hover': 'home-item--folder-hover':
@@ -1538,7 +1775,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag" @dragcancel="stopHomeDrag"
@dragend="finishHomeDrag" @dragend="finishHomeDrag"
@dragmove="moveHomeDrag" @dragmove="moveHomeDrag"
@dragstart="startHomeDrag('dock', slot.index)" @dragstart="startHomeDrag('dock', slot.index, $event)"
@edit="enterEditMode" @edit="enterEditMode"
@remove="removeHomeApp(slot.app.id)" @remove="removeHomeApp(slot.app.id)"
@reorder="reorderHomeApp('dock', slot.index, $event)" @reorder="reorderHomeApp('dock', slot.index, $event)"
@@ -1552,6 +1789,7 @@ onBeforeUnmount(() => {
:data-home-item-key="slot.folder.id" :data-home-item-key="slot.folder.id"
:data-home-index="slot.index" :data-home-index="slot.index"
:edit-mode="editMode" :edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:folder="slot.folder" :folder="slot.folder"
:show-label="false" :show-label="false"
:class="{ :class="{
@@ -1561,7 +1799,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag" @dragcancel="stopHomeDrag"
@dragend="finishHomeDrag" @dragend="finishHomeDrag"
@dragmove="moveHomeDrag" @dragmove="moveHomeDrag"
@dragstart="startHomeDrag('dock', slot.index)" @dragstart="startHomeDrag('dock', slot.index, $event)"
@edit="enterEditMode" @edit="enterEditMode"
@open="openFolder(slot.folder.id)" @open="openFolder(slot.folder.id)"
@reorder="reorderHomeApp('dock', slot.index, $event)" @reorder="reorderHomeApp('dock', slot.index, $event)"
+1 -1
View File
@@ -791,7 +791,7 @@ onBeforeUnmount(() => {
flex-direction: column; flex-direction: column;
color: #f6f7f9; color: #f6f7f9;
background: #07090c; background: #07090c;
font-family: Inter, system-ui, sans-serif; font-family: var(--sky-font-family);
} }
.billing-app--light { .billing-app--light {
--billing-border: rgb(15 23 42 / 10%); --billing-border: rgb(15 23 42 / 10%);
+1 -7
View File
@@ -1156,13 +1156,7 @@ onMounted(async () => {
padding: 47px 0 24px; padding: 47px 0 24px;
background: var(--bg); background: var(--bg);
color: var(--label); color: var(--label);
font-family: font-family: var(--sky-font-family);
Inter,
-apple-system,
BlinkMacSystemFont,
'SF Pro Display',
system-ui,
sans-serif;
} }
.calendar--light { .calendar--light {
+26 -1
View File
@@ -134,10 +134,30 @@ async function requestPhoto(): Promise<void> {
window.setTimeout(() => void completeDevelopmentCapture(id, 'photo'), 700) window.setTimeout(() => void completeDevelopmentCapture(id, 'photo'), 700)
return return
} }
await nuiCall('media:requestUpload', { console.info('[Sky Phone Media] Camera requested a photo upload.', {
correlationId: id,
})
const response = await nuiCall('media:requestUpload', {
correlationId: id, correlationId: id,
mediaType: 'photo', mediaType: 'photo',
}) })
if (!response.success) {
console.error('[Sky Phone Media] Photo upload request was rejected.', {
correlationId: id,
error: response.error,
})
window.postMessage(
{
data: {
correlationId: id,
error: response.error ?? 'request_failed',
success: false,
},
type: 'media:uploadResult',
},
'*',
)
}
} }
function startRecording(): void { function startRecording(): void {
@@ -374,6 +394,11 @@ function onMessage(event: MessageEvent): void {
} else if (message.type === 'media:uploadResult') { } else if (message.type === 'media:uploadResult') {
const result = message.data as UploadResult const result = message.data as UploadResult
if (!result?.correlationId) return if (!result?.correlationId) return
console.info('[Sky Phone Media] Camera received an upload result.', {
correlationId: result.correlationId,
error: result.error,
success: result.success,
})
savingVideo.value = false savingVideo.value = false
if (result.success && result.media) { if (result.success && result.media) {
latestMedia.value = result.media latestMedia.value = result.media
+1 -1
View File
@@ -2195,7 +2195,7 @@ onMounted(async () => {
overflow: hidden; overflow: hidden;
background: #151613; background: #151613;
color: #f8f8f4; color: #f8f8f4;
font-family: Inter, system-ui, sans-serif; font-family: var(--sky-font-family);
} }
.citymarkt--light { .citymarkt--light {
--ink: #fff; --ink: #fff;
+1 -1
View File
@@ -3586,7 +3586,7 @@ onMounted(async () => {
overflow: hidden; overflow: hidden;
background: #12171b !important; background: #12171b !important;
color: #f7f8f4; color: #f7f8f4;
font-family: Inter, system-ui, sans-serif; font-family: var(--sky-font-family);
} }
.feather-app--active.feather-app--light { .feather-app--active.feather-app--light {
--feather-panel: #f0f1ec; --feather-panel: #f0f1ec;
+18 -4
View File
@@ -77,6 +77,10 @@ import {
SkyToggle, SkyToggle,
} from '@/ui' } from '@/ui'
import { nuiCall } from '@/utils/nui' import { nuiCall } from '@/utils/nui'
import {
getPhoneOutputVolume,
subscribePhoneOutputVolume,
} from '@/utils/phoneAudio'
type Tab = 'feed' | 'discover' | 'create' | 'activity' | 'profile' type Tab = 'feed' | 'discover' | 'create' | 'activity' | 'profile'
type AuthMode = 'login' | 'register' type AuthMode = 'login' | 'register'
@@ -194,6 +198,8 @@ let flipTokYoutubePlayer: YouTubePlayer | null = null
let flipTokYoutubeApi: YouTubeApi | null = null let flipTokYoutubeApi: YouTubeApi | null = null
let flipTokYoutubeOwner = '' let flipTokYoutubeOwner = ''
let flipTokYoutubeVideoId = '' let flipTokYoutubeVideoId = ''
let flipTokYoutubeVolume = 0
let removePhoneOutputVolumeListener: (() => void) | undefined
let observer: IntersectionObserver | null = null let observer: IntersectionObserver | null = null
let videoClickTimer: number | null = null let videoClickTimer: number | null = null
let likePulseTimer: number | null = null let likePulseTimer: number | null = null
@@ -786,6 +792,7 @@ async function playFlipTokYoutube(
seconds = 0, seconds = 0,
): Promise<void> { ): Promise<void> {
flipTokYoutubeOwner = owner flipTokYoutubeOwner = owner
flipTokYoutubeVolume = volume
try { try {
const api = await loadYouTubeApi() const api = await loadYouTubeApi()
flipTokYoutubeApi = api flipTokYoutubeApi = api
@@ -794,7 +801,7 @@ async function playFlipTokYoutube(
flipTokYoutubeVideoId = videoId flipTokYoutubeVideoId = videoId
flipTokYoutubePlayer.loadVideoById(videoId) flipTokYoutubePlayer.loadVideoById(videoId)
} }
flipTokYoutubePlayer.setVolume(volume) flipTokYoutubePlayer.setVolume(volume * getPhoneOutputVolume())
flipTokYoutubePlayer.seekTo(Math.max(0, seconds), true) flipTokYoutubePlayer.seekTo(Math.max(0, seconds), true)
flipTokYoutubePlayer.playVideo() flipTokYoutubePlayer.playVideo()
if (owner === 'composer') customMusicLoadFailed.value = false if (owner === 'composer') customMusicLoadFailed.value = false
@@ -835,7 +842,7 @@ async function playFlipTokYoutube(
}, },
onReady: (event) => { onReady: (event) => {
flipTokYoutubePlayer = event.target flipTokYoutubePlayer = event.target
event.target.setVolume(volume) event.target.setVolume(volume * getPhoneOutputVolume())
event.target.seekTo(Math.max(0, seconds), true) event.target.seekTo(Math.max(0, seconds), true)
event.target.playVideo() event.target.playVideo()
if (owner === 'composer') customMusicLoadFailed.value = false if (owner === 'composer') customMusicLoadFailed.value = false
@@ -1627,7 +1634,10 @@ watch(originalVolume, (value) => {
watch(musicVolume, (value) => { watch(musicVolume, (value) => {
if (composerMusic.value) composerMusic.value.volume = value / 100 if (composerMusic.value) composerMusic.value.volume = value / 100
if (flipTokYoutubeOwner === 'composer') flipTokYoutubePlayer?.setVolume(value) if (flipTokYoutubeOwner === 'composer') {
flipTokYoutubeVolume = value
flipTokYoutubePlayer?.setVolume(value * getPhoneOutputVolume())
}
}) })
watch( watch(
@@ -1655,6 +1665,9 @@ watch(
) )
onMounted(async () => { onMounted(async () => {
removePhoneOutputVolumeListener = subscribePhoneOutputVolume((volume) => {
flipTokYoutubePlayer?.setVolume(flipTokYoutubeVolume * volume)
})
const profileSelection = messageMedia.consumeMany<ProfileMediaContext>( const profileSelection = messageMedia.consumeMany<ProfileMediaContext>(
'fliptok:profile-avatar', 'fliptok:profile-avatar',
) )
@@ -1731,6 +1744,7 @@ onMounted(async () => {
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
removePhoneOutputVolumeListener?.()
observer?.disconnect() observer?.disconnect()
if (videoClickTimer !== null) window.clearTimeout(videoClickTimer) if (videoClickTimer !== null) window.clearTimeout(videoClickTimer)
if (likePulseTimer !== null) window.clearTimeout(likePulseTimer) if (likePulseTimer !== null) window.clearTimeout(likePulseTimer)
@@ -3381,7 +3395,7 @@ onBeforeUnmount(() => {
overflow: hidden; overflow: hidden;
background: #000; background: #000;
color: #fff; color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif; font-family: var(--sky-font-family);
} }
.state, .state,
.light-empty, .light-empty,
+1 -1
View File
@@ -1526,7 +1526,7 @@ onMounted(async () => {
overflow: hidden; overflow: hidden;
background: #12171b; background: #12171b;
color: #f7f7f2; color: #f7f7f2;
font-family: Inter, system-ui, sans-serif; font-family: var(--sky-font-family);
} }
.pages--light { .pages--light {
--yellow: #8a6500; --yellow: #8a6500;
+1 -1
View File
@@ -279,7 +279,7 @@ onBeforeUnmount(() => {
background: background:
radial-gradient(circle at 88% 8%, rgb(255 255 255 / 72%), transparent 28%), radial-gradient(circle at 88% 8%, rgb(255 255 255 / 72%), transparent 28%),
linear-gradient(155deg, #f4efff 0%, #e7ddff 55%, #d9cdf7 100%); linear-gradient(155deg, #f4efff 0%, #e7ddff 55%, #d9cdf7 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: var(--sky-font-family);
user-select: none; user-select: none;
} }
+1 -1
View File
@@ -420,7 +420,7 @@ onBeforeUnmount(() => {
background: background:
radial-gradient(circle at 88% 7%, rgb(108 231 218 / 32%), transparent 30%), radial-gradient(circle at 88% 7%, rgb(108 231 218 / 32%), transparent 30%),
linear-gradient(155deg, #effcf7 0%, #cfeee5 52%, #abdcd7 100%); linear-gradient(155deg, #effcf7 0%, #cfeee5 52%, #abdcd7 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: var(--sky-font-family);
touch-action: manipulation; touch-action: manipulation;
user-select: none; user-select: none;
} }
+1 -1
View File
@@ -386,7 +386,7 @@ onBeforeUnmount(() => {
padding: 52px 14px 25px; padding: 52px 14px 25px;
color: #f5fbff; color: #f5fbff;
background: radial-gradient(circle at 50% 0, #253163, #090c22 58%, #050715); background: radial-gradient(circle at 50% 0, #253163, #090c22 58%, #050715);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: var(--sky-font-family);
user-select: none; user-select: none;
touch-action: manipulation; touch-action: manipulation;
} }
+1 -1
View File
@@ -335,7 +335,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
background: background:
radial-gradient(circle at 86% 8%, rgb(255 192 94 / 28%), transparent 29%), radial-gradient(circle at 86% 8%, rgb(255 192 94 / 28%), transparent 29%),
linear-gradient(155deg, #fff3dc 0%, #f3d8b7 55%, #e8b98d 100%); linear-gradient(155deg, #fff3dc 0%, #f3d8b7 55%, #e8b98d 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: var(--sky-font-family);
touch-action: none; touch-action: none;
user-select: none; user-select: none;
} }
+1 -1
View File
@@ -167,7 +167,7 @@ onBeforeUnmount(() => {
</template> </template>
<style scoped> <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:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;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:var(--sky-font-family);user-select:none;touch-action:manipulation; }
.flappy-app--playing { padding:0; } .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-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} .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: background:
radial-gradient(circle at 85% 8%, rgb(100 211 91 / 22%), transparent 30%), radial-gradient(circle at 85% 8%, rgb(100 211 91 / 22%), transparent 30%),
linear-gradient(165deg, #142b25 0%, #0c1715 62%, #08100f 100%); linear-gradient(165deg, #142b25 0%, #0c1715 62%, #08100f 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: var(--sky-font-family);
user-select: none; user-select: none;
touch-action: none; touch-action: none;
} }
+1 -1
View File
@@ -321,7 +321,7 @@ onBeforeUnmount(() => {
</template> </template>
<style scoped> <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: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 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: var(--sky-font-family); user-select: none; touch-action: manipulation; }
.tower-app--playing { padding: 0; } .tower-app--playing { padding: 0; }
.tower-header { height: 50px; display: flex; align-items: center; justify-content: space-between; } .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; } .tower-header span { display: block; color: #c1b8f1; font-size: 10px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
@@ -0,0 +1,70 @@
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"',
)
})
})
@@ -0,0 +1,303 @@
<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,9 +229,7 @@ describe('development Sky UI Kitchen Sink contract', () => {
/const developmentRoutes[^=]*=\s*import\.meta\.env\.DEV[\s\S]*?import\('@\/views\/development\/SkyUiKitchenSinkView\.vue'\)/, /const developmentRoutes[^=]*=\s*import\.meta\.env\.DEV[\s\S]*?import\('@\/views\/development\/SkyUiKitchenSinkView\.vue'\)/,
) )
expect(routerSource).toContain("name: 'development-sky-ui'") expect(routerSource).toContain("name: 'development-sky-ui'")
expect(appSource).toContain( expect(appSource).toContain("route.name === 'development-sky-ui'")
"isDevelopment && route.name === 'development-sky-ui'",
)
expect(appSource).toContain( expect(appSource).toContain(
'isDevelopmentRoute ? String(route.name) : route.path', 'isDevelopmentRoute ? String(route.name) : route.path',
) )
+1
View File
@@ -23,6 +23,7 @@ const lifecycleEndpoints = new Set([
'notification:focus', 'notification:focus',
'sim:picker-close', 'sim:picker-close',
'ui:input-focus', 'ui:input-focus',
'ui:live-activity',
'ui:opened', 'ui:opened',
'ui:ready', 'ui:ready',
]) ])
+1
View File
@@ -1157,6 +1157,7 @@ async function main() {
'notification:focus', 'notification:focus',
'sim:picker-close', 'sim:picker-close',
'ui:input-focus', 'ui:input-focus',
'ui:live-activity',
'ui:opened', 'ui:opened',
'ui:ready', 'ui:ready',
] ]
+91
View File
@@ -0,0 +1,91 @@
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.
+3 -1
View File
@@ -1426,7 +1426,9 @@ Locales["de"] = {
errors = { errors = {
cancelled = "Die Aufnahme ist abgesagt.", capture_failed = "Die Spielansicht kann nicht erfasst werden.", cancelled = "Die Aufnahme ist abgesagt.", capture_failed = "Die Spielansicht kann nicht erfasst werden.",
invalid_media_type = "Der hochgeladene Medientyp ist ungültig.", invalid_upload = "Der Upload konnte nicht überprüft werden.", invalid_media_type = "Der hochgeladene Medientyp ist ungültig.", invalid_upload = "Der Upload konnte nicht überprüft werden.",
invalid_upload_token = "Die Upload-Sitzung ist nicht mehr gültig.", missing_config = "Kamera-Uploads sind nicht konfiguriert.", invalid_upload_token = "Die Upload-Sitzung ist nicht mehr gültig.", media_provider_failed = "Der Kamera-Upload-Dienst ist nicht verfügbar.",
media_provider_rate_limited = "Der Kamera-Upload-Dienst ist ausgelastet. Versuch es gleich erneut.", media_provider_unauthorized = "Der konfigurierte FiveManage API-Schlüssel wurde abgelehnt.",
missing_config = "Kamera-Uploads sind nicht konfiguriert.",
microphone_unavailable = "Erlaube den Mikrofonzugriff oder schalte das Mikrofon vor der Aufnahme stumm.", microphone_unavailable = "Erlaube den Mikrofonzugriff oder schalte das Mikrofon vor der Aufnahme stumm.",
not_found = "Das Medienobjekt existiert nicht mehr.", owner_changed = "Das aktive Telefonkonto hat sich beim Upload geändert.", not_found = "Das Medienobjekt existiert nicht mehr.", owner_changed = "Das aktive Telefonkonto hat sich beim Upload geändert.",
operation_in_progress = "Eine weitere Medienoperation ist bereits im Gange.", operation_in_progress = "Eine weitere Medienoperation ist bereits im Gange.",
+3 -1
View File
@@ -1426,7 +1426,9 @@ Locales["en"] = {
errors = { errors = {
cancelled = "Capture cancelled.", capture_failed = "Unable to capture the game view.", cancelled = "Capture cancelled.", capture_failed = "Unable to capture the game view.",
invalid_media_type = "The uploaded media type is invalid.", invalid_upload = "The upload could not be verified.", invalid_media_type = "The uploaded media type is invalid.", invalid_upload = "The upload could not be verified.",
invalid_upload_token = "The upload session is no longer valid.", missing_config = "Camera uploads are not configured.", invalid_upload_token = "The upload session is no longer valid.", media_provider_failed = "The camera upload service is unavailable.",
media_provider_rate_limited = "The camera upload service is busy. Try again shortly.", media_provider_unauthorized = "The configured FiveManage API key was rejected.",
missing_config = "Camera uploads are not configured.",
microphone_unavailable = "Allow microphone access or mute the microphone before recording.", microphone_unavailable = "Allow microphone access or mute the microphone before recording.",
not_found = "The media item no longer exists.", owner_changed = "The active phone account changed during upload.", not_found = "The media item no longer exists.", owner_changed = "The active phone account changed during upload.",
operation_in_progress = "Another media operation is already in progress.", operation_in_progress = "Another media operation is already in progress.",
+2 -1
View File
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
author 'Sky-Systems' author 'Sky-Systems'
description 'Sky Phone' description 'Sky Phone'
version '0.1.0' version '0.2.0'
provide 'lb-phone' provide 'lb-phone'
provide '17mov_Phone' provide '17mov_Phone'
@@ -106,6 +106,7 @@ server_scripts {
'source/server/notifications.lua', 'source/server/notifications.lua',
'source/shared/public_api.lua', 'source/shared/public_api.lua',
'source/server/public_api.lua', 'source/server/public_api.lua',
'source/server/media_provider_config.lua',
'source/server/media_import.lua', 'source/server/media_import.lua',
'source/server/media_import/fivemanage.lua', 'source/server/media_import/fivemanage.lua',
'source/server/media_import/manifest.lua', 'source/server/media_import/manifest.lua',
+42
View File
@@ -448,6 +448,13 @@ RegisterNUICallback("media:requestUpload", function(data, cb)
cb({ success = false, error = "invalid_request" }) cb({ success = false, error = "invalid_request" })
return return
end end
Bridge.Debug(
"debug",
"[sky_phone][media-debug] NUI requested an upload (correlation=%s, type=%s).",
tostring(data.correlationId),
tostring(data.mediaType),
{ notice = true }
)
TriggerServerEvent("sky_phone:media:request-upload", data) TriggerServerEvent("sky_phone:media:request-upload", data)
cb({ success = true }) cb({ success = true })
end) end)
@@ -457,6 +464,15 @@ RegisterNUICallback("media:completeUpload", function(data, cb)
cb({ success = false, error = "invalid_request" }) cb({ success = false, error = "invalid_request" })
return return
end end
Bridge.Debug(
"debug",
"[sky_phone][media-debug] NUI completed the provider upload (correlation=%s, remote-id=%s, url=%s, original-url=%s).",
tostring(data.correlationId),
type(data.remoteId) == "string" and "present" or "missing",
type(data.url) == "string" and "present" or "missing",
type(data.originalUrl) == "string" and "present" or "missing",
{ notice = true }
)
TriggerServerEvent("sky_phone:media:complete-upload", data) TriggerServerEvent("sky_phone:media:complete-upload", data)
cb({ success = true }) cb({ success = true })
end) end)
@@ -475,6 +491,16 @@ RegisterNUICallback("media:failUpload", function(data, cb)
cb({ success = false, error = "invalid_request" }) cb({ success = false, error = "invalid_request" })
return return
end end
local debug_message = tostring(data.debugMessage or "unknown"):gsub("[\r\n]", " "):sub(1, 240)
Bridge.Debug(
"error",
"[sky_phone][media-debug] NUI reported an upload failure (correlation=%s, error=%s, stage=%s, status=%s, detail=%s).",
tostring(data.correlationId),
tostring(data.error),
tostring(data.debugStage),
tostring(data.debugStatus),
debug_message
)
TriggerServerEvent("sky_phone:media:fail-upload", data) TriggerServerEvent("sky_phone:media:fail-upload", data)
cb({ success = true }) cb({ success = true })
end) end)
@@ -534,10 +560,26 @@ RegisterNUICallback("memos:failUpload", function(data, cb)
end) end)
RegisterNetEvent("sky_phone:media:upload-ready", function(data) RegisterNetEvent("sky_phone:media:upload-ready", function(data)
Bridge.Debug(
"debug",
"[sky_phone][media-debug] Client received upload-ready (correlation=%s, type=%s, presigned-url=%s).",
tostring(type(data) == "table" and data.correlationId),
tostring(type(data) == "table" and data.mediaType),
type(data) == "table" and type(data.presignedUrl) == "string" and "present" or "missing",
{ notice = true }
)
SendNUIMessage({ type = "media:uploadReady", data = data }) SendNUIMessage({ type = "media:uploadReady", data = data })
end) end)
RegisterNetEvent("sky_phone:media:upload-result", function(data) RegisterNetEvent("sky_phone:media:upload-result", function(data)
Bridge.Debug(
"debug",
"[sky_phone][media-debug] Client received upload-result (correlation=%s, success=%s, error=%s).",
tostring(type(data) == "table" and data.correlationId),
tostring(type(data) == "table" and data.success),
tostring(type(data) == "table" and data.error),
{ notice = true }
)
SendNUIMessage({ type = "media:uploadResult", data = data }) SendNUIMessage({ type = "media:uploadResult", data = data })
end) end)
+34
View File
@@ -6,6 +6,8 @@ local open_without_focus = false
local device_payload = nil local device_payload = nil
local equipped_phone_number = nil local equipped_phone_number = nil
local nui_generation = 0 local nui_generation = 0
local live_activity_active = false
local open_home_requested = false
local function get_equipped_phone_number() local function get_equipped_phone_number()
if not device_payload or not device_payload.device.sim then if not device_payload or not device_payload.device.sim then
@@ -75,7 +77,9 @@ local function send_open_message()
SendNUIMessage({ SendNUIMessage({
type = "app:open", type = "app:open",
data = payload, data = payload,
openHome = open_home_requested,
}) })
open_home_requested = false
end end
local function open_phone() local function open_phone()
@@ -169,6 +173,25 @@ RegisterCommand("sky_phone_toggle", function()
Bridge.Callbacks.Trigger("sky_phone:device:open-request", {}) Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
end, false) 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 Config.Phone.Keybind then
if type(Config.Phone.Keybind) ~= "string" or 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.") error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.")
@@ -255,6 +278,15 @@ RegisterNUICallback("ui:input-focus", function(data, cb)
cb({ success = true }) cb({ success = true })
end) 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
cb({ success = true })
end)
RegisterNUICallback("close", function(data, cb) RegisterNUICallback("close", function(data, cb)
if type(data) ~= "table" then if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" }) cb({ success = false, error = "invalid_request" })
@@ -302,6 +334,8 @@ RegisterNetEvent("sky_phone:device:invalidated", function()
close_phone(false) close_phone(false)
device_payload = nil device_payload = nil
update_equipped_phone_number(nil) update_equipped_phone_number(nil)
live_activity_active = false
open_home_requested = false
end) end)
RegisterNetEvent("sky_phone:device:error", function(error_code) RegisterNetEvent("sky_phone:device:error", function(error_code)
+10 -12
View File
@@ -679,18 +679,16 @@ function SkyPhoneCompanies.GetCallTargets(company_id)
end end
for source, readiness in pairs(call_availability) do for source, readiness in pairs(call_availability) do
local member = online[source] and call_member(source) or nil local member = online[source] and call_member(source) or nil
local device = member and current_device(source, true) or nil local device = member and SkyPhone.LoadDevice(readiness.imei) or nil
if not member or not device or member.company_id ~= readiness.company_id local device_slots = device and SkyPhone.FindDeviceSlots(source, readiness.imei) or {}
or readiness.company_id ~= company_id or readiness.imei ~= device.imei local readiness_valid = member and device and device_slots[1]
or readiness.sim_id ~= device.sim_id and member.company_id == readiness.company_id
then and device.sim_id == readiness.sim_id
if not member or not device or member.company_id ~= readiness.company_id and device.sim_type == "registered"
or readiness.imei ~= (device and device.imei) and device.registered_at ~= nil
or readiness.sim_id ~= (device and device.sim_id) if not readiness_valid then
then call_availability[source] = nil
call_availability[source] = nil elseif readiness.company_id == company_id then
end
else
targets[#targets + 1] = { targets[#targets + 1] = {
source = source, source = source,
simId = device.sim_id, simId = device.sim_id,
+216 -113
View File
@@ -4,36 +4,19 @@ SkyPhoneMediaImport.Initialize()
local pending_uploads = {} local pending_uploads = {}
local pending_deletes = {} local pending_deletes = {}
local allowed_remote_mimes = { local allowed_fivemanage_hosts = {
audio = { ["api.fivemanage.com"] = true,
["audio/ogg"] = true, ["fmapi.net"] = true,
["audio/webm"] = true,
},
photo = {
["image/jpeg"] = true,
["image/png"] = true,
["image/webp"] = true,
},
video = {
["video/mp4"] = true,
["video/webm"] = true,
},
} }
local function media_config() local function diagnostic_text(value, maximum_length)
return Config.Media.FiveManage return tostring(value or "unknown"):gsub("[\r\n]", " "):sub(1, maximum_length)
end end
local function media_api_key() local function media_debug(message, ...)
local api_key = media_config().ApiKey local arguments = { ... }
if type(api_key) ~= "string" then arguments[#arguments + 1] = { notice = true }
return "" Bridge.Debug("debug", "[sky_phone][media-debug] " .. message, table.unpack(arguments))
end
return api_key:match("^%s*(.-)%s*$")
end
local function api_configured()
return media_api_key() ~= ""
end end
local function http_request(url, method, body, headers, timeout_ms) local function http_request(url, method, body, headers, timeout_ms)
@@ -65,6 +48,9 @@ local function response_error_message(response)
if type(response) ~= "table" then if type(response) ~= "table" then
return "invalid response" return "invalid response"
end end
if type(response.status) == "number" and response.status >= 200 and response.status < 300 then
return "none"
end
if type(response.error) == "string" and response.error ~= "" then if type(response.error) == "string" and response.error ~= "" then
return response.error:sub(1, 240) return response.error:sub(1, 240)
end end
@@ -75,6 +61,10 @@ local function response_error_message(response)
return message:sub(1, 240) return message:sub(1, 240)
end end
end end
local response_body = tostring(response.body or ""):gsub("[\r\n]", " ")
if response_body ~= "" then
return response_body:sub(1, 240)
end
return "no provider error message" return "no provider error message"
end end
@@ -95,8 +85,22 @@ local function decode_response(response)
return decoded.data or decoded return decoded.data or decoded
end end
local function fivemanage_file_base(value)
if type(value) ~= "string" then
return nil
end
local host = value:match("^https://([^/%?#]+)")
host = host and host:lower() or nil
if not host or not allowed_fivemanage_hosts[host] then
return nil
end
return ("https://%s/api/v3/file"):format(host), host
end
local function request_presigned_url() local function request_presigned_url()
if not api_configured() then local api_key = SkyPhoneMediaProviderConfig.FiveManageApiKey()
media_debug("Starting FiveManage presigned upload request (api-key=%s).", api_key ~= "" and "present" or "missing")
if api_key == "" then
Bridge.Debug( Bridge.Debug(
"error", "error",
"[sky_phone] FiveManage presigned upload request failed: Config.Media.FiveManage.ApiKey is empty or invalid.", "[sky_phone] FiveManage presigned upload request failed: Config.Media.FiveManage.ApiKey is empty or invalid.",
@@ -109,9 +113,10 @@ local function request_presigned_url()
tostring(config.BaseUrl):gsub("/+$", "") .. "/presigned-url", tostring(config.BaseUrl):gsub("/+$", "") .. "/presigned-url",
"GET", "GET",
"", "",
{ ["Authorization"] = media_api_key() }, { ["Authorization"] = api_key },
tonumber(config.RequestTimeoutMs) or 10000 tonumber(config.RequestTimeoutMs) or 10000
) )
media_debug("FiveManage presigned upload request returned HTTP %s.", tostring(response.status))
if response.status == 401 or response.status == 403 then if response.status == 401 or response.status == 403 then
Bridge.Debug( Bridge.Debug(
"error", "error",
@@ -150,40 +155,41 @@ local function request_presigned_url()
) )
return nil, "media_provider_failed" return nil, "media_provider_failed"
end end
local provider_base_url, provider_host = fivemanage_file_base(presigned_url)
if not provider_base_url then
Bridge.Debug(
"error",
"[sky_phone] FiveManage returned a presigned URL on an unexpected host.",
{ always = true }
)
return nil, "media_provider_failed"
end
media_debug("FiveManage returned a valid presigned upload URL (host=%s).", provider_host)
return presigned_url return presigned_url
end end
local function get_remote_file(remote_id) local function encode_remote_path(value)
if not api_configured() then local segments = {}
return nil, "missing_config" for segment in tostring(value):gmatch("[^/]+") do
segments[#segments + 1] = SkyPhoneMediaImport.UrlEncode(segment)
end end
local config = Config.Media.FiveManage return table.concat(segments, "/")
local response = http_request(
("%s/%s"):format(
tostring(config.BaseUrl):gsub("/+$", ""),
SkyPhoneMediaImport.UrlEncode(remote_id)
),
"GET",
"",
{ ["Authorization"] = media_api_key() },
tonumber(config.RequestTimeoutMs) or 10000
)
return decode_response(response)
end end
local function delete_remote_file(remote_id) local function delete_remote_file(remote_id)
if not api_configured() then local api_key = SkyPhoneMediaProviderConfig.FiveManageApiKey()
if api_key == "" then
return false, "missing_config" return false, "missing_config"
end end
local config = Config.Media.FiveManage local config = Config.Media.FiveManage
local response = http_request( local response = http_request(
("%s/%s"):format( ("%s/%s"):format(
tostring(config.BaseUrl):gsub("/+$", ""), tostring(config.BaseUrl):gsub("/+$", ""),
SkyPhoneMediaImport.UrlEncode(remote_id) encode_remote_path(remote_id)
), ),
"DELETE", "DELETE",
"", "",
{ ["Authorization"] = media_api_key() }, { ["Authorization"] = api_key },
tonumber(config.RequestTimeoutMs) or 10000 tonumber(config.RequestTimeoutMs) or 10000
) )
if response.status < 200 or response.status >= 300 then if response.status < 200 or response.status >= 300 then
@@ -289,6 +295,14 @@ function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type)
end end
local function upload_result(source, correlation_id, success, error_code, media) local function upload_result(source, correlation_id, success, error_code, media)
media_debug(
"Sending upload result (source=%s, correlation=%s, success=%s, error=%s, media=%s).",
tostring(source),
diagnostic_text(correlation_id, 80),
tostring(success),
diagnostic_text(error_code, 80),
type(media) == "table" and "present" or "missing"
)
TriggerClientEvent("sky_phone:media:upload-result", source, { TriggerClientEvent("sky_phone:media:upload-result", source, {
correlationId = correlation_id, correlationId = correlation_id,
success = success, success = success,
@@ -315,79 +329,42 @@ local function delete_many_result(source, correlation_id, success, error_code, d
}) })
end end
local function parse_metadata(value)
if type(value) == "table" then
return value
end
if type(value) ~= "string" then
return nil
end
local success, decoded = pcall(json.decode, value)
return success and type(decoded) == "table" and decoded or nil
end
local function valid_remote_id(value) local function valid_remote_id(value)
return type(value) == "string" and #value >= 4 and #value <= 128 and value:match("^[%w_%-]+$") ~= nil return type(value) == "string" and #value >= 4 and #value <= 128 and value:match("^[%w_%-]+$") ~= nil
end end
local function verify_remote_upload(state, remote_id, uploaded_url) local function verify_remote_upload(state, remote_id, uploaded_url)
if not valid_remote_id(remote_id) or type(uploaded_url) ~= "string" or #uploaded_url > 2048 if not valid_remote_id(remote_id) or type(uploaded_url) ~= "string"
or #uploaded_url > Config.Media.UrlMaxLength
or not uploaded_url:match("^https://") or not uploaded_url:match("^https://")
then then
Bridge.Debug(
"error",
"[sky_phone][media-debug] Upload completion payload is invalid (remote-id=%s, url=%s).",
valid_remote_id(remote_id) and "valid" or "invalid",
type(uploaded_url) == "string" and uploaded_url:match("^https://") and "https" or "invalid"
)
return nil, "invalid_upload" return nil, "invalid_upload"
end end
local remote, remote_error = get_remote_file(remote_id)
if not remote then local uploaded_host = uploaded_url:match("^https://([^/%?#]+)")
return nil, remote_error if not uploaded_host or uploaded_host:lower() ~= "r2.fivemanage.com" then
end Bridge.Debug(
if remote.id ~= remote_id then "error",
"[sky_phone][media-debug] FiveManage upload returned an unexpected media host."
)
return nil, "invalid_upload" return nil, "invalid_upload"
end end
if remote.url ~= uploaded_url and remote.originalUrl ~= uploaded_url then media_debug(
return nil, "invalid_upload" "Accepting the direct FiveManage upload response (type=%s, size=%s).",
end tostring(state.media_type),
local verified_url = remote.url or uploaded_url tostring(state.size_bytes)
if type(verified_url) ~= "string" or #verified_url > Config.Media.UrlMaxLength )
or not verified_url:match("^https://")
then
return nil, "invalid_upload"
end
local metadata = parse_metadata(remote.metadata)
if not metadata or metadata.captureToken ~= state.capture_token or metadata.source ~= "sky_phone" then
return nil, "invalid_upload_token"
end
if state.purpose and metadata.purpose ~= state.purpose then
return nil, "invalid_upload", true
end
local allowed_mimes = allowed_remote_mimes[state.media_type]
if not allowed_mimes then
return nil, "invalid_media_type", true
end
local remote_mime = tostring(remote.mimeType or ""):lower():match("^%s*([^;%s]+)") or ""
local remote_type = tostring(remote.type or ""):lower():match("^%s*([^;%s]+)") or ""
if remote_mime == "" and allowed_mimes[remote_type] then
remote_mime = remote_type
end
if remote_type == "" then
remote_type = remote_mime
end
if state.media_type == "photo" and remote_type ~= "" and not remote_type:find("image", 1, true) then
return nil, "invalid_media_type", true
end
if state.media_type == "video" and remote_type ~= "" and not remote_type:find("video", 1, true) then
return nil, "invalid_media_type", true
end
if state.media_type == "audio" and remote_type ~= "" and not remote_type:find("audio", 1, true) then
return nil, "invalid_media_type", true
end
if remote_mime ~= "" and not allowed_mimes[remote_mime] then
return nil, "invalid_media_type", true
end
return { return {
mime_type = allowed_mimes[remote_mime] and remote_mime or state.mime_type, mime_type = state.mime_type,
remote_id = remote_id, remote_id = remote_id,
size = tonumber(remote.size), size = state.size_bytes,
url = verified_url, url = uploaded_url,
}, nil, true }, nil, true
end end
@@ -401,6 +378,12 @@ local function expire_upload(request_id)
return return
end end
pending_uploads[request_id] = nil pending_uploads[request_id] = nil
Bridge.Debug(
"error",
"[sky_phone][media-debug] Upload session expired before completion (source=%s, correlation=%s).",
tostring(state.source),
diagnostic_text(state.correlation_id, 80)
)
upload_result(state.source, state.correlation_id, false, "upload_timeout") upload_result(state.source, state.correlation_id, false, "upload_timeout")
end end
@@ -660,35 +643,81 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data)
data = type(data) == "table" and data or {} data = type(data) == "table" and data or {}
local correlation_id = data.correlationId local correlation_id = data.correlationId
local media_type = data.mediaType local media_type = data.mediaType
media_debug(
"Server received upload request (source=%s, correlation=%s, type=%s).",
tostring(src),
diagnostic_text(correlation_id, 80),
diagnostic_text(media_type, 20)
)
if type(correlation_id) ~= "string" or #correlation_id > 80 if type(correlation_id) ~= "string" or #correlation_id > 80
or (media_type ~= "photo" and media_type ~= "video") or (media_type ~= "photo" and media_type ~= "video")
then then
Bridge.Debug(
"error",
"[sky_phone][media-debug] Upload request validation failed (source=%s, correlation-type=%s, correlation-length=%s, media-type=%s).",
tostring(src),
type(correlation_id),
type(correlation_id) == "string" and tostring(#correlation_id) or "invalid",
diagnostic_text(media_type, 20)
)
upload_result(src, correlation_id, false, "invalid_request") upload_result(src, correlation_id, false, "invalid_request")
return return
end end
if not SkyPhone.AllowOperation(src, "media_write", 20, 60) then if not SkyPhone.AllowOperation(src, "media_write", 20, 60) then
Bridge.Debug(
"warn",
"[sky_phone][media-debug] Upload request was rate limited (source=%s, correlation=%s).",
tostring(src),
diagnostic_text(correlation_id, 80)
)
upload_result(src, correlation_id, false, "rate_limited") upload_result(src, correlation_id, false, "rate_limited")
return return
end end
local owner, error_response = session_owner(src) local owner, error_response = session_owner(src)
if not owner then if not owner then
Bridge.Debug(
"error",
"[sky_phone][media-debug] Upload request has no valid phone session (source=%s, correlation=%s, error=%s).",
tostring(src),
diagnostic_text(correlation_id, 80),
diagnostic_text(error_response and error_response.error, 80)
)
upload_result(src, correlation_id, false, error_response.error) upload_result(src, correlation_id, false, error_response.error)
return return
end end
media_debug(
"Upload request session resolved (source=%s, correlation=%s, owner=%s).",
tostring(src),
diagnostic_text(correlation_id, 80),
owner.account_id and "account" or "device"
)
local presigned_url, presigned_error = request_presigned_url() local presigned_url, presigned_error = request_presigned_url()
if not presigned_url then if not presigned_url then
Bridge.Debug(
"error",
"[sky_phone][media-debug] Presigned upload request failed (source=%s, correlation=%s, error=%s).",
tostring(src),
diagnostic_text(correlation_id, 80),
diagnostic_text(presigned_error, 80)
)
upload_result(src, correlation_id, false, presigned_error) upload_result(src, correlation_id, false, presigned_error)
return return
end end
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`, UUID() AS `capture_token`", {}) local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`", {})
local request_id = ids[1] and ids[1].request_id local request_id = ids[1] and ids[1].request_id
local capture_token = ids[1] and ids[1].capture_token if type(request_id) ~= "string" then
if type(request_id) ~= "string" or type(capture_token) ~= "string" then Bridge.Debug(
"error",
"[sky_phone][media-debug] Database did not generate an upload request ID (source=%s, correlation=%s, rows=%s, request-id=%s).",
tostring(src),
diagnostic_text(correlation_id, 80),
tostring(type(ids) == "table" and #ids or 0),
type(request_id)
)
upload_result(src, correlation_id, false, "request_failed") upload_result(src, correlation_id, false, "request_failed")
return return
end end
pending_uploads[request_id] = { pending_uploads[request_id] = {
capture_token = capture_token,
correlation_id = correlation_id, correlation_id = correlation_id,
media_type = media_type, media_type = media_type,
mime_type = media_type == "video" and "video/webm" mime_type = media_type == "video" and "video/webm"
@@ -700,8 +729,13 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data)
SetTimeout(tonumber(Config.Media.UploadSessionTimeoutMs) or 60000, function() SetTimeout(tonumber(Config.Media.UploadSessionTimeoutMs) or 60000, function()
expire_upload(request_id) expire_upload(request_id)
end) end)
media_debug(
"Sending upload-ready to client (source=%s, correlation=%s, type=%s).",
tostring(src),
diagnostic_text(correlation_id, 80),
tostring(media_type)
)
TriggerClientEvent("sky_phone:media:upload-ready", src, { TriggerClientEvent("sky_phone:media:upload-ready", src, {
captureToken = capture_token,
correlationId = correlation_id, correlationId = correlation_id,
mediaType = media_type, mediaType = media_type,
photo = Config.Media.Photo, photo = Config.Media.Photo,
@@ -718,18 +752,55 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data)
local request_id = data.requestId local request_id = data.requestId
local state = type(request_id) == "string" and pending_uploads[request_id] or nil local state = type(request_id) == "string" and pending_uploads[request_id] or nil
if not state or state.source ~= src or state.completing then if not state or state.source ~= src or state.completing then
Bridge.Debug(
"warn",
"[sky_phone][media-debug] Rejected upload completion (source=%s, request-id=%s, session=%s, owner-match=%s, completing=%s).",
tostring(src),
type(request_id) == "string" and "present" or "invalid",
state and "present" or "missing",
tostring(state and state.source == src),
tostring(state and state.completing)
)
return return
end end
media_debug(
"Server received upload completion (source=%s, correlation=%s, remote-id=%s, url=%s).",
tostring(src),
diagnostic_text(state.correlation_id, 80),
type(data.remoteId) == "string" and "present" or "missing",
type(data.url) == "string" and "present" or "missing"
)
state.completing = true state.completing = true
local owner, error_response = session_owner(src) local owner, error_response = session_owner(src)
if not owner or not owners_match(owner, state.owner) then if not owner or not owners_match(owner, state.owner) then
pending_uploads[request_id] = nil pending_uploads[request_id] = nil
Bridge.Debug(
"error",
"[sky_phone][media-debug] Upload owner changed before completion (source=%s, correlation=%s, session=%s, owner-match=%s, error=%s).",
tostring(src),
diagnostic_text(state.correlation_id, 80),
owner and "present" or "missing",
tostring(owner and owners_match(owner, state.owner)),
diagnostic_text(error_response and error_response.error, 80)
)
upload_result(src, state.correlation_id, false, error_response and error_response.error or "owner_changed") upload_result(src, state.correlation_id, false, error_response and error_response.error or "owner_changed")
return return
end end
local verified, verify_error, trusted_remote = verify_remote_upload(state, data.remoteId, data.url) local verified, verify_error, trusted_remote = verify_remote_upload(
state,
data.remoteId,
data.url
)
if not verified then if not verified then
pending_uploads[request_id] = nil pending_uploads[request_id] = nil
Bridge.Debug(
"error",
"[sky_phone][media-debug] Uploaded file verification failed (source=%s, correlation=%s, error=%s, trusted-remote=%s).",
tostring(src),
diagnostic_text(state.correlation_id, 80),
diagnostic_text(verify_error, 80),
tostring(trusted_remote)
)
if trusted_remote then if trusted_remote then
local deleted, delete_error = delete_remote_file(data.remoteId) local deleted, delete_error = delete_remote_file(data.remoteId)
if not deleted then if not deleted then
@@ -760,9 +831,22 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data)
local media_id = type(result) == "number" and result or (type(result) == "table" and tonumber(result.insertId)) local media_id = type(result) == "number" and result or (type(result) == "table" and tonumber(result.insertId))
if not media_id then if not media_id then
delete_remote_file(verified.remote_id) delete_remote_file(verified.remote_id)
Bridge.Debug(
"error",
"[sky_phone][media-debug] Database insert did not return a media ID (source=%s, correlation=%s, result-type=%s).",
tostring(src),
diagnostic_text(state.correlation_id, 80),
type(result)
)
upload_result(src, state.correlation_id, false, "request_failed") upload_result(src, state.correlation_id, false, "request_failed")
return return
end end
media_debug(
"Media upload completed successfully (source=%s, correlation=%s, media-id=%s).",
tostring(src),
diagnostic_text(state.correlation_id, 80),
tostring(media_id)
)
upload_result(src, state.correlation_id, true, nil, { upload_result(src, state.correlation_id, true, nil, {
id = media_id, id = media_id,
url = verified.url, url = verified.url,
@@ -787,6 +871,15 @@ RegisterNetEvent("sky_phone:media:fail-upload", function(data)
local request_id = type(data) == "table" and data.requestId or nil local request_id = type(data) == "table" and data.requestId or nil
local state = type(request_id) == "string" and pending_uploads[request_id] or nil local state = type(request_id) == "string" and pending_uploads[request_id] or nil
if not state or state.source ~= src or state.completing then if not state or state.source ~= src or state.completing then
Bridge.Debug(
"warn",
"[sky_phone][media-debug] Rejected client upload failure report (source=%s, request-id=%s, session=%s, owner-match=%s, completing=%s).",
tostring(src),
type(request_id) == "string" and "present" or "invalid",
state and "present" or "missing",
tostring(state and state.source == src),
tostring(state and state.completing)
)
return return
end end
local allowed_errors = { local allowed_errors = {
@@ -797,6 +890,16 @@ RegisterNetEvent("sky_phone:media:fail-upload", function(data)
} }
pending_uploads[request_id] = nil pending_uploads[request_id] = nil
local error_code = allowed_errors[data.error] and data.error or "upload_failed" local error_code = allowed_errors[data.error] and data.error or "upload_failed"
Bridge.Debug(
"error",
"[sky_phone][media-debug] Client-reported upload failure (source=%s, correlation=%s, error=%s, stage=%s, status=%s, detail=%s).",
tostring(src),
diagnostic_text(state.correlation_id, 80),
diagnostic_text(error_code, 80),
diagnostic_text(data.debugStage, 40),
diagnostic_text(data.debugStatus, 20),
diagnostic_text(data.debugMessage, 240)
)
upload_result(src, state.correlation_id, false, error_code) upload_result(src, state.correlation_id, false, error_code)
end) end)
@@ -981,7 +1084,7 @@ AddEventHandler("playerDropped", function()
end end
end) end)
if not api_configured() then if SkyPhoneMediaProviderConfig.FiveManageApiKey() == "" then
Bridge.Debug( Bridge.Debug(
"warn", "warn",
"[sky_phone] FiveManage media integration is disabled because Config.Media.FiveManage.ApiKey is empty in config/media.lua. Camera photo and video uploads, Voice Memo uploads, remote Gallery deletion, and FiveManage imports are unavailable. Add a FiveManage V3 token with Media access and restart sky_phone.", "[sky_phone] FiveManage media integration is disabled because Config.Media.FiveManage.ApiKey is empty in config/media.lua. Camera photo and video uploads, Voice Memo uploads, remote Gallery deletion, and FiveManage imports are unavailable. Add a FiveManage V3 token with Media access and restart sky_phone.",
@@ -1,9 +1,5 @@
local function api_key(website) local function api_key(website)
local configured_key = website.ApiKey or Config.Media.FiveManage.ApiKey return SkyPhoneMediaProviderConfig.FiveManageApiKey(website.ApiKey)
if type(configured_key) ~= "string" then
return ""
end
return configured_key:match("^%s*(.-)%s*$")
end end
local function provider_error(response, not_found_error) local function provider_error(response, not_found_error)
@@ -0,0 +1,17 @@
SkyPhoneMediaProviderConfig = {}
local function trim_key(value)
if type(value) ~= "string" then
return ""
end
return value:match("^%s*(.-)%s*$")
end
function SkyPhoneMediaProviderConfig.FiveManageApiKey(override_key)
local api_key = trim_key(override_key)
if api_key ~= "" then
return api_key
end
return trim_key(Config.Media.FiveManage.ApiKey)
end
+37 -11
View File
@@ -51,6 +51,23 @@ local function session_owner(source)
} }
end 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 function owner_condition(owner, alias)
local prefix = alias and ("`%s`."):format(alias) or "" local prefix = alias and ("`%s`."):format(alias) or ""
if owner.account_id then if owner.account_id then
@@ -153,6 +170,7 @@ local function validate_upload(data)
local title_length = title and utf8.len(title) or nil local title_length = title and utf8.len(title) or nil
local note_length = note and utf8.len(note) or nil local note_length = note and utf8.len(note) or nil
local duration_ms = tonumber(data.durationMs) local duration_ms = tonumber(data.durationMs)
local size_bytes = tonumber(data.sizeBytes)
local normalized_mime = allowed_audio_mimes[data.mimeType] local normalized_mime = allowed_audio_mimes[data.mimeType]
local waveform = normalize_waveform(data.waveform) local waveform = normalize_waveform(data.waveform)
if not title_length or title_length < 1 or title_length > Config.Memos.TitleMaxLength if not title_length or title_length < 1 or title_length > Config.Memos.TitleMaxLength
@@ -160,6 +178,8 @@ local function validate_upload(data)
or not duration_ms or duration_ms ~= duration_ms or not duration_ms or duration_ms ~= duration_ms
or duration_ms == math.huge or duration_ms == -math.huge or duration_ms == math.huge or duration_ms == -math.huge
or duration_ms < 300 or duration_ms > Config.Memos.MaximumDurationMs or duration_ms < 300 or duration_ms > Config.Memos.MaximumDurationMs
or not size_bytes or size_bytes ~= math.floor(size_bytes)
or size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes
or not normalized_mime or not waveform or type(data.pinned) ~= "boolean" or not normalized_mime or not waveform or type(data.pinned) ~= "boolean"
then then
return nil return nil
@@ -171,6 +191,7 @@ local function validate_upload(data)
mime_type = normalized_mime, mime_type = normalized_mime,
note = note, note = note,
pinned = data.pinned, pinned = data.pinned,
size_bytes = size_bytes,
title = title, title = title,
waveform = waveform, waveform = waveform,
} }
@@ -317,7 +338,7 @@ RegisterNetEvent("sky_phone:memos:request-upload", function(data)
upload_result(src, type(data) == "table" and data.correlationId or nil, false, "invalid_memo") upload_result(src, type(data) == "table" and data.correlationId or nil, false, "invalid_memo")
return return
end end
local owner, error_response = session_owner(src) local owner, error_response = device_owner(src, data.deviceImei)
if not owner then if not owner then
upload_result(src, memo.correlation_id, false, error_response.error) upload_result(src, memo.correlation_id, false, error_response.error)
return return
@@ -356,20 +377,18 @@ RegisterNetEvent("sky_phone:memos:request-upload", function(data)
upload_result(src, memo.correlation_id, false, presigned_error) upload_result(src, memo.correlation_id, false, presigned_error)
return return
end end
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`, UUID() AS `capture_token`", {}) local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`", {})
local request_id = ids[1] and ids[1].request_id local request_id = ids[1] and ids[1].request_id
local capture_token = ids[1] and ids[1].capture_token if type(request_id) ~= "string" then
if type(request_id) ~= "string" or type(capture_token) ~= "string" then error("[sky_phone] Database did not generate a voice memo upload request ID.")
error("[sky_phone] Database did not generate a voice memo upload token.")
end end
pending_uploads[request_id] = { pending_uploads[request_id] = {
capture_token = capture_token,
media_type = "audio", media_type = "audio",
mime_type = memo.mime_type, mime_type = memo.mime_type,
memo = memo, memo = memo,
owner = owner, owner = owner,
owner_key = pending_owner_key, owner_key = pending_owner_key,
purpose = "memo", size_bytes = memo.size_bytes,
source = src, source = src,
} }
SetTimeout(Config.Memos.UploadSessionTimeoutMs, function() SetTimeout(Config.Memos.UploadSessionTimeoutMs, function()
@@ -378,7 +397,6 @@ RegisterNetEvent("sky_phone:memos:request-upload", function(data)
TriggerClientEvent("sky_phone:memos:upload-ready", src, { TriggerClientEvent("sky_phone:memos:upload-ready", src, {
requestId = request_id, requestId = request_id,
correlationId = memo.correlation_id, correlationId = memo.correlation_id,
captureToken = capture_token,
presignedUrl = presigned_url, presignedUrl = presigned_url,
uploadTimeoutMs = Config.Media.FiveManage.UploadTimeoutMs, uploadTimeoutMs = Config.Media.FiveManage.UploadTimeoutMs,
}) })
@@ -392,17 +410,25 @@ RegisterNetEvent("sky_phone:memos:complete-upload", function(data)
return return
end end
state.completing = true state.completing = true
local owner, error_response = session_owner(src) local owner, error_response = device_owner(src, state.owner.imei)
if not owner or owner.imei ~= state.owner.imei or owner.account_id ~= state.owner.account_id then if not owner or owner.imei ~= state.owner.imei or owner.account_id ~= state.owner.account_id then
pending_uploads[request_id] = nil pending_uploads[request_id] = nil
local rejected, _, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url) local rejected, _, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(
state,
data.remoteId,
data.url
)
if rejected or trusted_remote then if rejected or trusted_remote then
discard_verified_upload(data.remoteId) discard_verified_upload(data.remoteId)
end end
upload_result(src, state.memo.correlation_id, false, error_response and error_response.error or "owner_changed") upload_result(src, state.memo.correlation_id, false, error_response and error_response.error or "owner_changed")
return return
end end
local verified, verify_error, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url) local verified, verify_error, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(
state,
data.remoteId,
data.url
)
if not verified then if not verified then
pending_uploads[request_id] = nil pending_uploads[request_id] = nil
if trusted_remote then if trusted_remote then
-1
View File
@@ -958,7 +958,6 @@ Bridge.Debug(
) )
Bridge.Callbacks.Register("sky_phone:device:close", function(source) Bridge.Callbacks.Register("sky_phone:device:close", function(source)
SkyPhoneCompanies.ClearCallAvailability(source)
sessions[source] = nil sessions[source] = nil
return { success = true } return { success = true }
end) end)