Compare commits

..

7 Commits

Author SHA1 Message Date
Leon.Schmidt b361922e2a FIX - allow movement outside phone text fields 2026-08-20 15:10:20 +02:00
smx.pusha daa67cfb1e ENH - refine phone setup experience (#12) 2026-08-20 08:55:03 +02:00
DerEchteAlec 3eae2b8d30 ADD - check GitHub releases on startup (#10)
Compare the fxmanifest version against the latest published sky_phone release tag and report update, current, ahead, and failure states without blocking resource startup.

Document the startup check and cover the HTTP and version comparison behavior with a focused Lua test.
2026-08-19 20:57:02 +02:00
Dominik 734f4651cb ADD - add housing provider bridges (#6)
Co-authored-by: DerEchteAlec <alec.schitzkat@luwan.io>
2026-08-19 20:11:36 +02:00
DerEchteAlec 98373e8bd8 FIX - await setup persistence before completion (#9) 2026-08-19 20:08:33 +02:00
DerEchteAlec 64a7bc78c6 FIX - Improve test data idempotency and reliability (#7)
Refactor test data seeding to use deterministic identifiers for DarkChat and Flare, ensuring re-runs don't create duplicate entries or inconsistent states. Implement robust SIM card movement logic and add contract tests to verify seeding constraints and default configuration safety.
2026-08-19 19:41:54 +02:00
Leon.Schmidt 7985ca08d9 ADD - integrate Yaca voice support (#5) 2026-08-19 17:04:23 +02:00
59 changed files with 4994 additions and 477 deletions
+11 -4
View File
@@ -87,9 +87,9 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
| --- | --- |
| **Frameworks** | ESX Legacy, QBCore, Qbox |
| **Inventories** | ox_inventory, qb-inventory, lj-inventory, qs-inventory, codem-inventory, core_inventory, mf-inventory, smx-inventory, hex_4_inventory, and native ESX inventory |
| **Calls** | PMA Voice, SaltyChat |
| **Calls** | YACA, PMA Voice, SaltyChat |
| **Radio** | YACA, PMA Voice, SaltyChat |
| **Housing** | ESX Property, qbx_properties |
| **Housing** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
| **Garages** | Built-in/custom data and a broad set of popular garage providers configured through the bridge |
| **Custom app contracts** | Sky Phone, LB Phone, 17Movement, High Phone, Quasar Smartphone, YSeries |
| **Languages** | English, German |
@@ -140,6 +140,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
Phone calls support:
- YACA
- PMA Voice
- SaltyChat
@@ -248,6 +249,11 @@ When enabled, Sky Phone prints debug and informational messages. Warnings and er
The short LB Phone detection notice also remains visible when debug mode is disabled.
On every resource start, Sky Phone compares the `version` in `fxmanifest.lua` with the tag of the
latest published [GitHub release](https://github.com/sky-systems/sky_phone/releases/latest). The
server console reports whether the installed version is current and shows the release link when an
update is available. A failed GitHub request is reported but does not prevent the phone from starting.
## Security values
Sky Phone ships with stable generated defaults in `Config.Server`:
@@ -433,10 +439,11 @@ Config.Calls.VoiceProvider = "pma"
Supported values:
- `yaca` or `yaca-voice`
- `pma` or `pma-voice`
- `saltychat` or `salty`
SaltyChat supports the provider-backed call speaker feature. PMA Voice keeps the speaker option unavailable.
YACA supports calls, payphone calls, provider-backed speaker mode, and real microphone mute. SaltyChat supports provider-backed speaker mode. PMA Voice keeps speaker and mute controls unavailable.
### Radio
@@ -523,7 +530,7 @@ Select the provider under `Config.Garage.System`. Vehicle images use the configu
### Housing
Select the provider under `Config.Housing.System`. Automatic mode supports the configured provider priority.
Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API.
### Companies
+47 -7
View File
@@ -77,6 +77,7 @@ import { nuiCall } from '@/utils/nui'
import { formatTimer } from '@/utils/clock'
import { parsePhonePreferences } from '@/utils/preferences'
import { getHairlinePixelStyle } from '@/utils/rendering'
import { isTextInputElement } from '@/utils/textInputFocus'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
import SpringboardView from '@/views/SpringboardView.vue'
@@ -271,12 +272,14 @@ const MIN_PRODUCTION_PHONE_ZOOM = 260 / PHONE_PORTRAIT_WIDTH
const developmentParameters = new URLSearchParams(window.location.search)
const isBrowserPreview =
developmentParameters.has('browserPreview') &&
developmentParameters.get('apiBase')?.startsWith('/') === true
(import.meta.env.DEV ||
developmentParameters.get('apiBase')?.startsWith('/') === true)
const isDevelopment =
import.meta.env.DEV ||
developmentParameters.get('apiBase')?.startsWith('/') === true
const developmentLockScreenPreview =
isDevelopment && developmentParameters.has('lockScreenPreview')
let textInputFocused = false
const phone = usePhoneStore()
const account = useAccountStore()
@@ -346,6 +349,7 @@ const previousHardwareAlertVolume = ref(75)
const hardwareVolumeHudVisible = ref(false)
const setupPreviewDismissed = ref(false)
const setupDevelopmentSkipped = ref(false)
const setupAppearanceSelected = ref(false)
const pendingUnlockRoute = ref<string | null>(null)
const unlockedServicesLoaded = ref(false)
const controlCenterOpened = ref(false)
@@ -359,6 +363,13 @@ const setupRequired = computed(
developmentParameters.has('setupPreview') &&
!setupPreviewDismissed.value)),
)
const displayedDarkMode = computed(
() =>
phone.isDarkMode &&
(!setupRequired.value ||
setupAppearanceSelected.value ||
phone.preferences.settings.setupStep > 4),
)
const hardwareAlertVolume = computed(() =>
Math.round(
(phone.preferences.settings.notificationVolume +
@@ -572,6 +583,7 @@ function loadUnlockedPhoneData(): void {
function completePhoneSetup(): void {
setupPreviewDismissed.value = true
setupAppearanceSelected.value = false
isLocked.value = false
isUnlocking.value = false
passcodeVisible.value = false
@@ -1394,7 +1406,29 @@ function unlockCamera(): void {
window.setTimeout(() => void router.push('/apps/camera'), 0)
}
function updateTextInputFocus(active: boolean): void {
if (textInputFocused === active) return
textInputFocused = active
void nuiCall('ui:input-focus', { active })
}
function onFocusIn(event: FocusEvent): void {
const target = event.target
updateTextInputFocus(
target instanceof HTMLElement && isTextInputElement(target),
)
}
function onFocusOut(event: FocusEvent): void {
const nextTarget = event.relatedTarget
updateTextInputFocus(
nextTarget instanceof HTMLElement && isTextInputElement(nextTarget),
)
}
onMounted(() => {
document.addEventListener('focusin', onFocusIn)
document.addEventListener('focusout', onFocusOut)
window.addEventListener('message', onMessage)
window.addEventListener('keydown', onKeydown)
window.addEventListener('resize', updateViewportScale)
@@ -1500,6 +1534,7 @@ watch(
(isOpen) => {
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
if (!isOpen) {
updateTextInputFocus(false)
cancelUnlockedPhoneDataLoad()
appStore.cancelPendingInstalls()
activitySuspended.value = false
@@ -1557,6 +1592,7 @@ watch(
)
onBeforeUnmount(() => {
updateTextInputFocus(false)
cancelUnlockedPhoneDataLoad()
weather.stop()
if (clockTicker) clearInterval(clockTicker)
@@ -1571,6 +1607,8 @@ onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('resize', updateViewportScale)
document.removeEventListener('focusin', onFocusIn)
document.removeEventListener('focusout', onFocusOut)
systemColorScheme.removeEventListener('change', onSystemColorSchemeChange)
})
</script>
@@ -1624,7 +1662,7 @@ onBeforeUnmount(() => {
<section
class="phone-device"
:class="{
'phone-app--light': !phone.isDarkMode,
'phone-app--light': !displayedDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
:aria-label="phone.t('Common.phone')"
@@ -1674,7 +1712,7 @@ onBeforeUnmount(() => {
class="phone-screen"
:class="{
'phone-screen--app': isAppRoute || isDevelopmentRoute,
'phone-app--light': !phone.isDarkMode,
'phone-app--light': !displayedDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
>
@@ -1711,18 +1749,19 @@ onBeforeUnmount(() => {
</Transition>
<k-app
theme="ios"
:dark="phone.isDarkMode"
:dark="displayedDarkMode"
safe-areas
class="phone-app"
:style="phoneDisplayStyle"
:class="{
dark: phone.isDarkMode,
'phone-app--light': !phone.isDarkMode,
dark: displayedDarkMode,
'phone-app--light': !displayedDarkMode,
'phone-app--messages': route.params.appId === 'messages',
'phone-app--status-light':
WHITE_STATUS_BAR_APP_IDS.has(activeAppId),
'phone-app--status-dark':
DARK_STATUS_BAR_APP_IDS.has(activeAppId),
'phone-app--setup': setupRequired,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
'phone-app--unlocking': isUnlocking,
}"
@@ -1742,7 +1781,7 @@ onBeforeUnmount(() => {
/>
<SkyProvider
class="phone-app-theme"
:dark="phone.isDarkMode"
:dark="displayedDarkMode"
safe-areas
>
<RouterView v-slot="{ Component }">
@@ -1801,6 +1840,7 @@ onBeforeUnmount(() => {
</Transition>
<PhoneSetupAssistant
v-if="setupRequired"
@appearance-selected="setupAppearanceSelected = $event"
@complete="completePhoneSetup"
@skip="skipPhoneSetupForDevelopment"
/>
@@ -9,6 +9,17 @@ const mainCss = readFileSync(
)
describe('browser development preview contract', () => {
it('keeps setup light until the appearance choice has been confirmed', () => {
expect(source).toContain('const displayedDarkMode = computed(')
expect(source).toContain('phone.preferences.settings.setupStep > 4')
expect(source).toContain('setupAppearanceSelected.value')
expect(source).toContain(
'@appearance-selected="setupAppearanceSelected = $event"',
)
expect(source).toContain(':dark="displayedDarkMode"')
expect(source).toContain('dark: displayedDarkMode')
})
it('starts unlocked while preserving an explicit lock screen preview', () => {
expect(source).toContain("developmentParameters.has('lockScreenPreview')")
expect(source).toContain(': !isDevelopment || developmentLockScreenPreview')
@@ -87,6 +98,7 @@ describe('browser development preview contract', () => {
it('fills the dedicated browser embed without clipping the hardware controls', () => {
expect(source).toContain("developmentParameters.has('browserPreview')")
expect(source).toContain('import.meta.env.DEV ||')
expect(source).toContain("'phone-stage--browser-preview': isBrowserPreview")
expect(source).toContain('return (availableScale * 0.94) / PHONE_BASE_SCALE')
expect(mainCss).toMatch(
+31 -322
View File
@@ -904,361 +904,63 @@ button {
}
.wallpaper--midnight {
background:
radial-gradient(circle at 76% 17%, #ffffff 0 0.7px, transparent 1.2px),
radial-gradient(circle at 28% 8%, #9fceff 0 0.6px, transparent 1px),
radial-gradient(circle at 82% 48%, #d8c8ff 0 0.8px, transparent 1.3px),
radial-gradient(
circle at 69% 19%,
#faf7ff 0 3%,
#a57be74d 8%,
transparent 23%
),
conic-gradient(
from 214deg at 63% 35%,
transparent 0 29%,
#7352a51f 36%,
transparent 44%
),
radial-gradient(ellipse at 10% 84%, #1767999e 0, transparent 45%),
linear-gradient(158deg, #050511 0%, #11132d 42%, #120a25 68%, #02040c 100%);
background-size:
43px 47px,
67px 71px,
89px 83px,
auto,
auto,
auto,
auto;
radial-gradient(circle at 78% 12%, rgb(87 103 160 / 18%), transparent 38%),
linear-gradient(160deg, #141724 0%, #090b12 100%);
}
.wallpaper--aurora {
background:
radial-gradient(circle at 17% 14%, #dfffff 0 0.6px, transparent 1.1px),
radial-gradient(circle at 78% 9%, #c6e7ff 0 0.7px, transparent 1.2px),
conic-gradient(
from 218deg at 84% 8%,
transparent 0 18%,
#4effbf70 27%,
#6bf5e220 35%,
transparent 43%
),
conic-gradient(
from 224deg at 62% 26%,
transparent 0 20%,
#27cfd46b 28%,
#695eff42 38%,
transparent 47%
),
radial-gradient(ellipse at 12% 80%, #1860bb9c 0%, transparent 48%),
radial-gradient(ellipse at 76% 20%, #2adfbb6e 0%, transparent 38%),
linear-gradient(155deg, #031715 0%, #08293a 43%, #151643 68%, #040914 100%);
background-size:
59px 61px,
83px 79px,
auto,
auto,
auto,
auto,
auto;
radial-gradient(circle at 74% 18%, rgb(80 188 166 / 22%), transparent 42%),
linear-gradient(155deg, #163c3c 0%, #17263c 56%, #0b101b 100%);
}
.wallpaper--ember {
background:
radial-gradient(
circle at 80% 18%,
#fff4ca 0 1.7%,
#ffbd67 5%,
#ff62335c 17%,
transparent 31%
),
repeating-radial-gradient(
ellipse at 78% 21%,
transparent 0 16px,
#ffb15e12 18px 20px
),
conic-gradient(
from 196deg at 12% 78%,
transparent 0 18%,
#ff3d4c66 28%,
#f3943a29 38%,
transparent 48%
),
conic-gradient(
from 22deg at 78% 90%,
transparent 0 22%,
#b52a755e 31%,
transparent 44%
),
radial-gradient(ellipse at 8% 71%, #c8205c91 0, transparent 46%),
linear-gradient(152deg, #20080a 0%, #4b171d 37%, #3c1537 66%, #090710 100%);
radial-gradient(circle at 82% 14%, rgb(237 142 96 / 28%), transparent 40%),
linear-gradient(155deg, #5b2925 0%, #321b25 58%, #17131b 100%);
}
.wallpaper--ocean {
background:
linear-gradient(116deg, transparent 43%, #c7fbff1c 47%, transparent 51%),
linear-gradient(64deg, transparent 44%, #8defff17 48%, transparent 52%),
radial-gradient(
ellipse at 18% 12%,
#d7ffff 0 1%,
#70e7ff85 12%,
transparent 34%
),
repeating-radial-gradient(
ellipse at 48% 107%,
transparent 0 24px,
#73e8ff2e 27px 30px,
transparent 33px 49px
),
radial-gradient(ellipse at 87% 70%, #056bd1ce 0, transparent 48%),
linear-gradient(
172deg,
#b5f4f8 0%,
#148eb0 22%,
#075b91 54%,
#03284d 78%,
#020c20 100%
);
background-size:
82px 82px,
82px 82px,
auto,
auto,
auto,
auto;
radial-gradient(circle at 22% 10%, rgb(119 203 222 / 28%), transparent 40%),
linear-gradient(175deg, #327e98 0%, #185273 46%, #0b2944 100%);
}
.wallpaper--sunrise {
background:
linear-gradient(18deg, #281c46 0 13%, transparent 13.3%),
linear-gradient(-22deg, #51304a 0 18%, transparent 18.3%),
radial-gradient(
circle at 58% 69%,
#fffbd7 0 5.5%,
#ffd28d 6%,
#ff9f666e 18%,
transparent 35%
),
repeating-linear-gradient(0deg, transparent 0 16px, #ffe2ae0d 17px 18px),
radial-gradient(ellipse at 20% 44%, #f888a360 0, transparent 42%),
linear-gradient(
178deg,
#342b78 0%,
#8a477f 35%,
#ed706f 57%,
#ffc277 72%,
#672e50 100%
);
radial-gradient(circle at 68% 68%, rgb(255 205 153 / 30%), transparent 42%),
linear-gradient(175deg, #6b6488 0%, #b46f78 52%, #d29673 100%);
}
.wallpaper--violet {
background:
radial-gradient(
circle at 78% 14%,
transparent 0 9%,
#e0b6ff35 9.5% 10.3%,
transparent 11%
),
radial-gradient(
circle at 18% 74%,
transparent 0 16%,
#a1a2ff29 16.5% 17.2%,
transparent 18%
),
conic-gradient(
from 202deg at 83% 25%,
transparent 0 17%,
#e48dff91 25%,
#8d63ff32 35%,
transparent 45%
),
linear-gradient(122deg, transparent 36%, #f0c9ff13 39%, transparent 42%),
radial-gradient(circle at 13% 81%, #5555e5c4 0, transparent 46%),
radial-gradient(circle at 72% 28%, #a53dcc70 0, transparent 41%),
linear-gradient(148deg, #11072b 0%, #391164 48%, #20103f 72%, #080519 100%);
radial-gradient(circle at 74% 18%, rgb(164 129 199 / 24%), transparent 42%),
linear-gradient(150deg, #513d69 0%, #302b50 56%, #171828 100%);
}
.wallpaper--forest {
background:
radial-gradient(
ellipse at 82% 12%,
transparent 0 7%,
#c8f49c2c 7.5% 8.2%,
transparent 9%
),
repeating-radial-gradient(
ellipse at -8% 108%,
transparent 0 34px,
#c1e6a218 36px 39px
),
linear-gradient(
135deg,
transparent 42%,
#bfe8a31c 45% 47%,
transparent 50%
),
conic-gradient(
from 55deg at 85% 24%,
transparent 0 24%,
#8dcf7361 31%,
transparent 39%
),
radial-gradient(ellipse at 76% 16%, #b7df8675 0, transparent 36%),
radial-gradient(ellipse at 10% 78%, #0b8b70b8 0, transparent 49%),
linear-gradient(162deg, #061713 0%, #144833 45%, #123326 68%, #040d0b 100%);
background-size:
auto,
auto,
auto,
auto,
auto,
auto,
100% 100%;
radial-gradient(circle at 76% 14%, rgb(126 161 121 / 24%), transparent 42%),
linear-gradient(160deg, #395c4c 0%, #243e35 54%, #13231f 100%);
}
.wallpaper--cobalt {
background:
linear-gradient(
145deg,
transparent 0 47%,
#c4ddff18 48% 49%,
transparent 50%
),
linear-gradient(
35deg,
transparent 0 47%,
#70a5ff12 48% 49%,
transparent 50%
),
radial-gradient(
circle at 76% 25%,
transparent 0 12%,
#62b8ff45 12.5% 13.2%,
transparent 14%
),
conic-gradient(
from 215deg at 80% 28%,
transparent 0 22%,
#52b5ff85 30%,
#5457d62e 41%,
transparent 48%
),
radial-gradient(circle at 14% 78%, #134ed1a3 0, transparent 43%),
linear-gradient(152deg, #041438 0%, #0b3182 43%, #172d82 64%, #030a25 100%);
background-size:
58px 58px,
58px 58px,
auto,
auto,
auto,
auto;
radial-gradient(circle at 72% 16%, rgb(91 142 218 / 28%), transparent 40%),
linear-gradient(155deg, #264f8b 0%, #1b3768 54%, #101e3b 100%);
}
.wallpaper--rose {
background:
radial-gradient(
circle at 16% 18%,
#fff6f3 0 1.5%,
#ffd9df9e 8%,
transparent 27%
),
repeating-radial-gradient(
ellipse at 108% 8%,
transparent 0 24px,
#ffd5e121 26px 28px
),
conic-gradient(
from 214deg at 86% 72%,
transparent 0 18%,
#f078aa8c 27%,
#8d3d7b38 39%,
transparent 48%
),
linear-gradient(138deg, transparent 38%, #fff0f217 43%, transparent 48%),
radial-gradient(circle at 87% 77%, #e84e92a8 0, transparent 43%),
linear-gradient(148deg, #40132f 0%, #8e3159 39%, #b64b70 59%, #351128 100%);
radial-gradient(circle at 24% 12%, rgb(224 154 172 / 26%), transparent 42%),
linear-gradient(155deg, #9a596c 0%, #724354 54%, #412936 100%);
}
.wallpaper--sand {
background:
radial-gradient(
circle at 76% 16%,
#fffce7 0 2.5%,
#ffe5a5a8 9%,
transparent 25%
),
repeating-radial-gradient(
ellipse at -2% 110%,
transparent 0 28px,
#fff3d12b 31px 34px,
transparent 37px 52px
),
conic-gradient(
from 82deg at 110% 76%,
transparent 0 24%,
#ecc37e52 32%,
transparent 43%
),
linear-gradient(115deg, transparent 41%, #fff6d21a 44%, transparent 47%),
radial-gradient(ellipse at 15% 78%, #9f654b9e 0, transparent 48%),
linear-gradient(163deg, #8b5c45 0%, #d4a262 43%, #b87950 68%, #4f302b 100%);
radial-gradient(circle at 76% 14%, rgb(222 190 145 / 25%), transparent 42%),
linear-gradient(160deg, #a17d61 0%, #80604e 56%, #513e36 100%);
}
.wallpaper--graphite {
background:
radial-gradient(circle at 77% 17%, #ffffff42 0 0.8%, transparent 12%),
linear-gradient(
125deg,
transparent 39%,
#ffffff12 40% 42%,
transparent 43%
),
linear-gradient(35deg, transparent 39%, #ffffff0a 40% 42%, transparent 43%),
repeating-linear-gradient(
92deg,
transparent 0 3px,
#ffffff09 4px,
transparent 5px 9px
),
conic-gradient(
from 215deg at 79% 24%,
transparent 0 25%,
#b7c1d045 32%,
transparent 43%
),
radial-gradient(circle at 17% 78%, #51596c73 0, transparent 44%),
linear-gradient(157deg, #06070a 0%, #242832 43%, #171920 67%, #030405 100%);
background-size:
auto,
54px 54px,
54px 54px,
100% 100%,
auto,
auto,
auto;
radial-gradient(circle at 76% 14%, rgb(133 140 151 / 18%), transparent 40%),
linear-gradient(160deg, #363a42 0%, #23262c 54%, #121417 100%);
}
.wallpaper--prism {
background:
linear-gradient(
132deg,
transparent 0 32%,
#ffffff36 32.5% 33.2%,
transparent 34%
),
linear-gradient(
42deg,
transparent 0 56%,
#bffcff25 56.5% 57.2%,
transparent 58%
),
conic-gradient(
from 218deg at 70% 29%,
#ff527ca1,
#ffc75f91,
#4cead2a6,
#4f72ffae,
#c64deaa3,
#ff527ca1
),
conic-gradient(
from 38deg at 16% 78%,
transparent 0 19%,
#4ae3d8a1 28%,
#596cff48 39%,
transparent 48%
),
radial-gradient(circle at 16% 76%, #35d8d399 0, transparent 38%),
linear-gradient(152deg, #120d35 0%, #47205f 46%, #10205a 70%, #041321 100%);
background-blend-mode: screen, screen, screen, screen, screen, normal;
radial-gradient(circle at 72% 16%, rgb(165 151 218 / 24%), transparent 42%),
linear-gradient(150deg, #655b86 0%, #42516f 52%, #293545 100%);
}
.wallpaper--custom {
background-color: #090a0d;
@@ -5475,6 +5177,13 @@ button {
color: #111;
text-shadow: 0 1px 3px #fff8;
}
.phone-app--setup .phone-status-bar {
color: #1d1d1f;
text-shadow: none;
}
.phone-app.dark.phone-app--setup .phone-status-bar {
color: #f5f5f7;
}
* {
scrollbar-width: none;
}
@@ -11,13 +11,20 @@ describe('PhoneSetupAssistant contract', () => {
it('uses first-party controls and exposes the complete setup journey', () => {
expect(source).not.toContain("from 'konsta/vue'")
expect(source).toContain('PhonePasscode')
expect(source).toContain("step === 1")
expect(source).toContain("step === 8")
expect(source).toContain('step === 1')
expect(source).toContain('step === 8')
expect(source).toContain("['performance', 'ultimate']")
expect(source).toContain('setup-mode-preview__card--front')
expect(source).toContain('setup-mode-preview__card--back')
expect(source).toContain('WALLPAPER_IDS')
expect(source).toContain('setAllAppNotifications')
expect(source).toContain('appStore.claimApp')
expect(source).toContain('phone.completeSetup()')
expect(source).toContain('await phone.completeSetup()')
expect(source).toMatch(
/if \(step\.value === 8\) \{[\s\S]*void finish\(\)[\s\S]*return/,
)
expect(source).toContain(':disabled="setupCompleteBusy"')
expect(source).toContain("phone.t('Setup.ready.saveFailed')")
})
it('persists progress and supports resuming or moving backward', () => {
@@ -25,4 +32,30 @@ describe('PhoneSetupAssistant contract', () => {
expect(source).toContain('phone.setSetupStep(step.value)')
expect(source).toContain('@click="moveTo(step - 1)"')
})
it('keeps the development skip control away from setup progress', () => {
expect(source).toContain('v-if="showDevelopmentSkip && step === 0"')
})
it('follows the selected system appearance throughout setup', () => {
expect(source).toContain(
'(appearanceSelected || step > 4) && phone.isDarkMode',
)
expect(source).toContain("emit('appearanceSelected', true)")
expect(source).toContain("phone.setPreference('appearanceMode', 'light')")
expect(source).toContain('.setup-assistant--dark.setup-assistant--step-0')
expect(source).toContain('--setup-background: #000000')
expect(source).toContain('background: var(--setup-background)')
})
it('applies the selected graphics mode to the setup experience immediately', () => {
expect(source).toContain("'setup-assistant--performance':")
expect(source).toContain("'setup-assistant--ultimate':")
expect(source).toContain(
'.setup-assistant--performance .setup-forward-enter-active',
)
expect(source).toContain(
'.setup-assistant--ultimate .setup-mode-stack button',
)
})
})
+537 -32
View File
@@ -9,6 +9,7 @@ import {
Palette,
ShieldCheck,
Signal,
Smartphone,
Sparkles,
Wifi,
} from 'lucide-vue-next'
@@ -30,7 +31,11 @@ import {
type WallpaperId,
} from '@/utils/preferences'
const emit = defineEmits<{ complete: []; skip: [] }>()
const emit = defineEmits<{
appearanceSelected: [selected: boolean]
complete: []
skip: []
}>()
const phone = usePhoneStore()
const account = useAccountStore()
@@ -38,6 +43,10 @@ const appStore = useAppStoreStore()
const step = ref(
Math.min(PHONE_SETUP_LAST_STEP, phone.preferences.settings.setupStep),
)
const appearanceSelected = ref(step.value > 4)
if (step.value === 4) {
phone.setPreference('appearanceMode', 'light')
}
const direction = ref<'back' | 'forward'>('forward')
const accountMode = ref<'login' | 'register'>('login')
const email = ref('')
@@ -53,6 +62,8 @@ const passcodeLength = ref<4 | 6>(phone.security.length === 4 ? 4 : 6)
const notificationsEnabled = ref(true)
const notificationSounds = ref(true)
const selectedApps = ref<BuiltinPhoneAppId[]>(['banking', 'garage', 'skyride'])
const setupCompleteBusy = ref(false)
const setupCompleteError = ref('')
const setupApps = (
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
@@ -91,6 +102,13 @@ const showDevelopmentSkip = import.meta.env.DEV
function moveTo(nextStep: number): void {
direction.value = nextStep < step.value ? 'back' : 'forward'
step.value = Math.min(PHONE_SETUP_LAST_STEP, Math.max(0, nextStep))
if (step.value < 4) {
appearanceSelected.value = false
emit('appearanceSelected', false)
}
if (step.value === 4 && !appearanceSelected.value) {
phone.setPreference('appearanceMode', 'light')
}
phone.setSetupStep(step.value)
}
@@ -103,12 +121,16 @@ function continueSetup(): void {
}
if (step.value === 8) {
for (const appId of selectedApps.value) appStore.claimApp(appId)
void finish()
return
}
moveTo(step.value + 1)
}
function chooseAppearance(mode: AppearanceMode): void {
appearanceSelected.value = true
phone.setPreference('appearanceMode', mode)
emit('appearanceSelected', true)
}
function choosePerformance(mode: GraphicsMode): void {
@@ -204,8 +226,16 @@ function choosePasscodeLength(length: 4 | 6): void {
passcodeResetKey.value += 1
}
function finish(): void {
phone.completeSetup()
async function finish(): Promise<void> {
if (setupCompleteBusy.value) return
setupCompleteBusy.value = true
setupCompleteError.value = ''
const completed = await phone.completeSetup()
setupCompleteBusy.value = false
if (!completed) {
setupCompleteError.value = phone.t('Setup.ready.saveFailed')
return
}
emit('complete')
}
@@ -217,13 +247,22 @@ function skipSetupForDevelopment(): void {
<template>
<section
class="setup-assistant"
:class="`setup-assistant--step-${step}`"
:class="[
`setup-assistant--step-${step}`,
{
'setup-assistant--dark':
(appearanceSelected || step > 4) && phone.isDarkMode,
'setup-assistant--performance':
phone.preferences.settings.graphicsMode === 'performance',
'setup-assistant--ultimate':
phone.preferences.settings.graphicsMode === 'ultimate',
},
]"
:style="currentWallpaperStyle"
:aria-label="phone.t('Setup.title')"
>
<div class="setup-assistant__aurora" aria-hidden="true"></div>
<button
v-if="showDevelopmentSkip"
v-if="showDevelopmentSkip && step === 0"
type="button"
class="setup-assistant__development-skip"
@click="skipSetupForDevelopment"
@@ -252,15 +291,8 @@ function skipSetupForDevelopment(): void {
<main :key="step" class="setup-assistant__page">
<template v-if="step === 0">
<div class="setup-welcome__hero" aria-hidden="true">
<div class="setup-welcome__greetings">
<span>{{ phone.t('Setup.welcome.hello') }}</span>
<span>{{ phone.t('Setup.welcome.hallo') }}</span>
<span>{{ phone.t('Setup.welcome.bonjour') }}</span>
</div>
<div class="setup-welcome__signature">
<span></span>
<span></span>
<span></span>
<div class="setup-welcome__device">
<Smartphone :size="43" :stroke-width="1.55" />
</div>
</div>
<div class="setup-welcome__copy">
@@ -273,19 +305,6 @@ function skipSetupForDevelopment(): void {
</p>
</div>
<footer class="setup-welcome__footer">
<div class="setup-welcome__privacy">
<span
><ShieldCheck :size="14" />{{
phone.t('Setup.welcome.private')
}}</span
>
<i aria-hidden="true"></i>
<span
><Sparkles :size="14" />{{
phone.t('Setup.welcome.personal')
}}</span
>
</div>
<SkyButton class="setup-assistant__primary" @click="continueSetup">
{{ phone.t('Setup.getStarted') }}
</SkyButton>
@@ -622,7 +641,20 @@ function skipSetupForDevelopment(): void {
<span
class="setup-mode-stack__orb"
:class="`setup-mode-stack__orb--${mode}`"
></span>
aria-hidden="true"
>
<i class="setup-mode-preview__backdrop"></i>
<i
class="setup-mode-preview__card setup-mode-preview__card--back"
></i>
<i
class="setup-mode-preview__card setup-mode-preview__card--front"
></i>
<i
class="setup-mode-preview__line setup-mode-preview__line--wide"
></i>
<i class="setup-mode-preview__line"></i>
</span>
<span
><strong>{{ phone.t(`Apps.settings.${mode}Mode`) }}</strong
><small>{{ phone.t(`Setup.performance.${mode}`) }}</small></span
@@ -794,12 +826,29 @@ function skipSetupForDevelopment(): void {
}}</b></span
>
</div>
<SkyButton class="setup-assistant__primary" @click="finish">{{
phone.t('Setup.ready.enter')
}}</SkyButton>
<SkyButton
class="setup-assistant__primary"
:disabled="setupCompleteBusy"
@click="finish"
>
<SkySpinner
v-if="setupCompleteBusy"
:label="phone.t('Setup.ready.saving')"
:size="18"
/>
<span v-else>{{ phone.t('Setup.ready.enter') }}</span>
</SkyButton>
<p
v-if="setupCompleteError"
class="setup-assistant__error"
role="alert"
>
{{ setupCompleteError }}
</p>
<button
type="button"
class="setup-assistant__later"
:disabled="setupCompleteBusy"
@click="moveTo(0)"
>
{{ phone.t('Setup.ready.review') }}
@@ -2099,6 +2148,462 @@ function skipSetupForDevelopment(): void {
opacity: 0;
transform: translateX(16px);
}
/* Minimal system setup language, matching the restrained iOS setup hierarchy. */
.setup-assistant,
.setup-assistant--step-0 {
--setup-background: #ffffff;
--setup-text: #1d1d1f;
--setup-secondary: #6e6e73;
--setup-tertiary: #8e8e93;
--setup-muted: #aeaeb2;
--setup-surface: #f2f2f7;
--setup-selected-surface: #eef6ff;
--setup-control: #e9e9eb;
--setup-control-fill: #ffffff;
--setup-separator: #d1d1d6;
--setup-blue: #007aff;
--setup-green: #34c759;
--setup-red: #ff3b30;
--setup-orb: #dcecff;
--setup-orb-ultimate: #e7e2ff;
--setup-home-indicator: #1d1d1f;
color: var(--setup-text);
background: var(--setup-background);
}
.setup-assistant--dark,
.setup-assistant--dark.setup-assistant--step-0 {
--setup-background: #000000;
--setup-text: #f5f5f7;
--setup-secondary: #98989d;
--setup-tertiary: #8e8e93;
--setup-muted: #636366;
--setup-surface: #1c1c1e;
--setup-selected-surface: #102a43;
--setup-control: #2c2c2e;
--setup-control-fill: #636366;
--setup-separator: #38383a;
--setup-blue: #0a84ff;
--setup-green: #30d158;
--setup-red: #ff453a;
--setup-orb: #102a43;
--setup-orb-ultimate: #28203d;
--setup-home-indicator: #ffffff;
}
.setup-assistant__development-skip {
top: 48px;
right: 13px;
border: 0;
color: var(--setup-blue);
background: transparent;
font-size: 11px;
font-weight: 600;
}
.setup-assistant__chrome {
gap: 10px;
}
.setup-assistant__back {
border: 0;
color: var(--setup-blue);
background: transparent;
backdrop-filter: none;
}
.setup-assistant__progress {
height: 3px;
background: var(--setup-separator);
}
.setup-assistant__progress span {
background: var(--setup-blue);
}
.setup-assistant__counter {
color: var(--setup-tertiary);
font-weight: 600;
}
.setup-assistant__page h1 {
margin: 12px 0 10px;
color: var(--setup-text);
font-size: 31px;
font-weight: 700;
line-height: 1.08;
letter-spacing: -0.04em;
}
.setup-assistant__lead {
color: var(--setup-secondary);
font-size: 14px;
line-height: 1.42;
}
.setup-assistant__eyebrow,
.setup-welcome__eyebrow {
display: none;
}
.setup-assistant__icon {
width: 72px;
height: 72px;
border: 0;
border-radius: 50%;
color: var(--setup-blue);
background: var(--setup-surface);
box-shadow: none;
backdrop-filter: none;
}
.setup-assistant__icon--signal,
.setup-assistant__icon--cloud,
.setup-assistant__icon--security,
.setup-assistant__icon--appearance,
.setup-assistant__icon--performance,
.setup-assistant__icon--wallpaper,
.setup-assistant__icon--notifications,
.setup-assistant__icon--apps {
color: var(--setup-blue);
}
.setup-assistant__primary {
min-height: 50px;
border-radius: 14px !important;
background: var(--setup-blue) !important;
box-shadow: none !important;
font-weight: 650;
}
.setup-assistant__later {
color: var(--setup-blue);
font-weight: 500;
}
.setup-assistant__notice {
padding: 5px 4px;
border: 0;
color: var(--setup-blue);
background: transparent;
}
.setup-assistant__notice p {
color: var(--setup-secondary);
}
.setup-assistant__error {
color: var(--setup-red);
}
.setup-assistant--step-0 .setup-assistant__page {
padding: 105px 28px 0;
}
.setup-welcome__hero {
width: 94px;
height: 94px;
border-radius: 50%;
background: var(--setup-surface);
}
.setup-welcome__hero::before,
.setup-welcome__hero::after {
content: none;
}
.setup-welcome__device {
display: grid;
width: 94px;
height: 94px;
color: var(--setup-blue);
place-items: center;
}
.setup-welcome__copy {
margin-top: 31px;
}
.setup-welcome__copy h1 {
margin-top: 0;
font-size: 32px;
}
.setup-welcome__footer {
width: calc(100% + 56px);
margin-right: -28px;
margin-left: -28px;
padding: 0 28px 13px;
border: 0;
background: var(--setup-background);
backdrop-filter: none;
}
.setup-welcome__footer .setup-assistant__primary {
margin-top: 0;
box-shadow: none;
}
.setup-welcome__home-indicator {
background: var(--setup-home-indicator);
}
.setup-connectivity-card {
min-height: 142px;
border: 0;
border-radius: 22px;
color: var(--setup-text);
background: var(--setup-surface);
box-shadow: none;
}
.setup-connectivity-card__waves {
display: none;
}
.setup-connectivity-card__label,
.setup-connectivity-card svg {
color: var(--setup-blue);
}
.setup-connectivity-card > span:last-of-type {
color: var(--setup-tertiary);
}
.setup-cloud-hero {
width: 72px;
height: 72px;
border-radius: 50%;
color: var(--setup-blue);
background: var(--setup-surface);
}
.setup-cloud-hero__orbit,
.setup-cloud-hero__glow,
.setup-cloud-hero i {
display: none;
}
.setup-cloud-hero svg {
filter: none;
}
.setup-cloud-identity {
border: 0;
background: var(--setup-surface);
}
.setup-cloud-identity > span {
border: 0;
background: var(--setup-blue);
box-shadow: none;
}
.setup-cloud-identity small,
.setup-cloud-strength,
.setup-cloud-security-note {
color: var(--setup-tertiary);
}
.setup-cloud-identity strong,
.setup-cloud-connected strong {
color: var(--setup-text);
}
.setup-cloud-identity em,
.setup-cloud-suffix {
color: var(--setup-blue);
}
.setup-cloud-fields {
border: 0;
background: var(--setup-surface);
}
.setup-cloud-fields :deep(.sky-field + .sky-field) {
border-top-color: var(--setup-separator);
}
.setup-cloud-fields :deep(.sky-field__label) {
color: var(--setup-tertiary);
}
.setup-cloud-fields :deep(.sky-field__input) {
color: var(--setup-text);
caret-color: var(--setup-blue);
}
.setup-cloud-fields :deep(.sky-field__input::placeholder) {
color: var(--setup-muted);
}
.setup-cloud-strength span {
background: var(--setup-separator);
}
.setup-cloud-strength span.active {
background: var(--setup-blue);
box-shadow: none;
}
.setup-assistant__selector {
background: var(--setup-control);
}
.setup-assistant__selector-indicator {
border: 0;
background: var(--setup-control-fill);
box-shadow: 0 1px 4px rgb(0 0 0 / 14%);
}
.setup-assistant__selector button {
color: var(--setup-secondary);
}
.setup-assistant__selector button.active {
color: var(--setup-text);
}
.setup-cloud-connected {
color: var(--setup-green);
}
.setup-cloud-connected span {
color: var(--setup-secondary);
}
.setup-security-visual {
border: 0;
background: var(--setup-surface);
}
.setup-security-visual span {
background: var(--setup-text);
box-shadow: none;
}
.setup-security-visual svg {
color: var(--setup-muted);
}
.setup-passcode-length button,
.setup-choice-grid button,
.setup-mode-stack button,
.setup-toggle-card,
.setup-app-list button,
.setup-ready__summary span {
border-color: transparent;
color: var(--setup-text);
background: var(--setup-surface);
box-shadow: none;
}
.setup-passcode-length button.selected,
.setup-choice-grid button.selected,
.setup-mode-stack button.selected,
.setup-app-list button.selected {
border-color: var(--setup-blue);
background: var(--setup-selected-surface);
box-shadow: none;
}
.setup-passcode-length small,
.setup-mode-stack small,
.setup-toggle-card small,
.setup-app-list small {
color: var(--setup-secondary);
}
.setup-mode-stack__orb {
position: relative;
overflow: hidden;
isolation: isolate;
border-radius: 18px;
background: var(--setup-orb);
box-shadow: none;
}
.setup-mode-stack__orb--ultimate {
background: var(--setup-orb-ultimate);
box-shadow: none;
}
.setup-mode-preview__backdrop,
.setup-mode-preview__card,
.setup-mode-preview__line {
position: absolute;
display: block;
pointer-events: none;
}
.setup-mode-preview__backdrop {
inset: 0;
background: linear-gradient(145deg, #d9eaff 0%, #b9d8ff 100%);
}
.setup-mode-preview__card {
width: 37px;
height: 26px;
border-radius: 8px;
}
.setup-mode-preview__card--back {
top: 11px;
left: 9px;
background: #ffffff;
}
.setup-mode-preview__card--front {
right: 8px;
bottom: 10px;
background: #e7f1ff;
}
.setup-mode-preview__line {
z-index: 2;
right: 15px;
bottom: 17px;
width: 17px;
height: 3px;
border-radius: 999px;
background: #5d80ad;
}
.setup-mode-preview__line--wide {
bottom: 23px;
width: 24px;
}
.setup-mode-stack__orb--ultimate .setup-mode-preview__backdrop {
background:
radial-gradient(circle at 20% 25%, #f4c7ff 0 12%, transparent 35%),
linear-gradient(145deg, #776de4 0%, #342760 100%);
}
.setup-mode-stack__orb--ultimate .setup-mode-preview__card {
border: 1px solid rgb(255 255 255 / 38%);
background: rgb(255 255 255 / 25%);
box-shadow: 0 7px 14px rgb(31 18 70 / 24%);
backdrop-filter: blur(5px);
}
.setup-mode-stack__orb--ultimate .setup-mode-preview__card--back {
transform: rotate(-8deg);
}
.setup-mode-stack__orb--ultimate .setup-mode-preview__card--front {
background: rgb(255 255 255 / 34%);
transform: rotate(5deg);
}
.setup-mode-stack__orb--ultimate .setup-mode-preview__line {
background: rgb(255 255 255 / 72%);
}
.setup-assistant--dark
.setup-mode-stack__orb--performance
.setup-mode-preview__backdrop {
background: linear-gradient(145deg, #203147 0%, #142133 100%);
}
.setup-assistant--dark
.setup-mode-stack__orb--performance
.setup-mode-preview__card--back {
background: #334965;
}
.setup-assistant--dark
.setup-mode-stack__orb--performance
.setup-mode-preview__card--front {
background: #29405d;
}
.setup-assistant--dark
.setup-mode-stack__orb--performance
.setup-mode-preview__line {
background: #a9c7e9;
}
.setup-mode-stack__check,
.setup-app-list button > i {
border-color: var(--setup-muted);
}
.selected .setup-mode-stack__check,
.setup-app-list button.selected > i {
border-color: var(--setup-blue);
background: var(--setup-blue);
}
.setup-wallpapers button.selected {
border-color: var(--setup-background);
box-shadow: 0 0 0 2px var(--setup-blue);
}
.setup-toggle-card {
border: 0;
}
.setup-toggle-card button {
color: var(--setup-text);
}
.setup-toggle-card button + button {
border-top-color: var(--setup-separator);
}
.setup-toggle-card i {
background: var(--setup-separator);
}
.setup-ready__halo {
width: 112px;
height: 112px;
border: 0;
color: var(--setup-blue);
background: var(--setup-surface);
box-shadow: none;
}
.setup-ready__summary span {
color: var(--setup-blue);
}
.setup-ready__summary b {
color: var(--setup-text);
}
.setup-assistant--performance .setup-forward-enter-active,
.setup-assistant--performance .setup-forward-leave-active,
.setup-assistant--performance .setup-back-enter-active,
.setup-assistant--performance .setup-back-leave-active,
.setup-assistant--performance .setup-assistant__progress span,
.setup-assistant--performance .setup-mode-stack button,
.setup-assistant--performance .setup-mode-stack__orb {
transition-duration: 0.01ms;
}
.setup-assistant--ultimate .setup-mode-stack button,
.setup-assistant--ultimate .setup-mode-stack__orb {
transition:
border-color 0.2s ease,
background-color 0.2s ease,
transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
}
@media (prefers-reduced-motion: reduce) {
.setup-assistant,
.setup-assistant *,
@@ -85,4 +85,16 @@ describe('phone inventory contracts', () => {
)
expect(phoneServer).toContain('preferred_device_imeis[source] = imei')
})
it('keeps non-unique phones bound to one persistent device per character', () => {
const phoneServer = readResourceFile('source/server/phone.lua')
const migration = readResourceFile('source/server/db_migrate.lua')
expect(phoneServer).toContain('if not unique_phones then')
expect(phoneServer).toContain('return map_character_device(source, slot)')
expect(phoneServer).toContain('FROM `sky_phone_character_devices`')
expect(phoneServer).toContain('WHERE `owner_identifier` = ?')
expect(migration).toContain('name = "sky_phone_character_devices"')
expect(migration).toContain('primaryKey = "owner_identifier"')
})
})
+48
View File
@@ -156,6 +156,54 @@ describe('calls store', () => {
expect(calls.activeCall?.speakerEnabled).toBe(false)
})
it('applies the provider-authoritative mute state for a Yaca call', async () => {
vi.mocked(nuiCall).mockResolvedValueOnce({
success: true,
data: { muted: true },
})
const calls = useCallsStore()
calls.applyCallState({
direction: 'outgoing',
id: 'call-yaca-mute',
muted: false,
muteSupported: true,
otherNumber: '5551110025',
startedAt: 1,
state: 'connected',
})
const response = await calls.setMuted(true)
expect(response.success).toBe(true)
expect(nuiCall).toHaveBeenCalledWith('calls:set-muted', {
enabled: true,
id: 'call-yaca-mute',
})
expect(calls.activeCall?.muted).toBe(true)
})
it('does not simulate mute state for unsupported voice providers', async () => {
const calls = useCallsStore()
calls.applyCallState({
direction: 'outgoing',
id: 'call-pma-mute',
muted: false,
muteSupported: false,
otherNumber: '5551110025',
startedAt: 1,
state: 'connected',
})
const response = await calls.setMuted(true)
expect(response).toEqual({
error: 'mute_unavailable',
success: false,
})
expect(nuiCall).not.toHaveBeenCalled()
expect(calls.activeCall?.muted).toBe(false)
})
it('updates a contact favorite and refreshes the contact list', async () => {
vi.mocked(nuiCall)
.mockResolvedValueOnce({
+25
View File
@@ -130,6 +130,30 @@ export const useCallsStore = defineStore('calls', () => {
return response
}
async function setMuted(
enabled: boolean,
): Promise<NuiResponse<{ muted: boolean }>> {
const call = activeCall.value
if (!call || call.state !== 'connected') {
return { success: false, error: 'call_not_connected' }
}
if (!call.muteSupported) {
return { success: false, error: 'mute_unavailable' }
}
const response = await nuiCall<{ muted: boolean }>('calls:set-muted', {
enabled,
id: call.id,
})
if (response.success && response.data && activeCall.value?.id === call.id) {
activeCall.value = {
...activeCall.value,
muted: response.data.muted === true,
}
}
return response
}
async function blockNumber(phoneNumber: string): Promise<NuiResponse> {
const response = await nuiCall('calls:block', { phoneNumber })
if (response.success && activeCall.value?.otherNumber === phoneNumber) {
@@ -179,6 +203,7 @@ export const useCallsStore = defineStore('calls', () => {
recents,
saveContact,
setContactFavorite,
setMuted,
setSpeaker,
}
})
@@ -167,4 +167,66 @@ describe('phone device persistence scope', () => {
expect(mockNuiCall).toHaveBeenCalledTimes(2)
expect(phone.deviceRevisions.widgets).toBe(1)
})
it('marks setup complete only after the settings save is acknowledged', async () => {
const completion = deferredResponse<{ revision: number }>()
mockNuiCall.mockReturnValueOnce(completion.promise)
const phone = usePhoneStore()
phone.open({
device: {
data: {
settings: {
payload: {
settings: { setupCompleted: false, setupStep: 9 },
version: 1,
},
revision: 3,
},
},
imei: '111',
name: 'Phone 111',
sim: null,
},
token: 'session-a',
})
const completed = phone.completeSetup()
await Promise.resolve()
expect(phone.preferences.settings.setupCompleted).toBe(false)
completion.resolve({ data: { revision: 4 }, success: true })
await expect(completed).resolves.toBe(true)
expect(phone.preferences.settings.setupCompleted).toBe(true)
expect(phone.deviceRevisions.settings).toBe(4)
})
it('keeps setup open when the completion save is rejected', async () => {
mockNuiCall.mockResolvedValueOnce({
error: 'request_failed',
success: false,
})
const phone = usePhoneStore()
phone.open({
device: {
data: {
settings: {
payload: {
settings: { setupCompleted: false, setupStep: 9 },
version: 1,
},
revision: 3,
},
},
imei: '111',
name: 'Phone 111',
sim: null,
},
token: 'session-a',
})
await expect(phone.completeSetup()).resolves.toBe(false)
expect(phone.preferences.settings.setupCompleted).toBe(false)
expect(phone.deviceRevisions.settings).toBe(3)
})
})
+30 -9
View File
@@ -43,7 +43,7 @@ export type PhoneOpenPayload = {
token?: string
}
const namespaceQueues = new Map<string, Promise<void>>()
const namespaceQueues = new Map<string, Promise<boolean>>()
let nextPersistenceSession = 0
const companiesFallbackLocales = {
@@ -2658,11 +2658,16 @@ const defaultLocales: LocaleTree = {
readonly_contact: 'Official company contacts cannot be changed.',
rate_limited: 'Too many calls. Try again in a minute.',
voice_unavailable: 'The configured phone voice service is unavailable.',
call_not_connected: 'Connect the call before enabling speaker mode.',
call_not_connected:
'Connect the call before changing its audio controls.',
speaker_unavailable:
'Speaker mode is not available for the configured phone voice service.',
speaker_unsupported:
'The configured phone voice service does not support speaker mode.',
mute_unavailable:
'Mute is not available for the configured phone voice service.',
mute_unsupported:
'The configured phone voice service does not support mute.',
inventory_full: 'There is no room for the ejected SIM card.',
operation_in_progress:
'Another phone operation is already in progress.',
@@ -4944,6 +4949,8 @@ const defaultLocales: LocaleTree = {
localOnly: 'Stored on this phone',
enter: 'Enter Sky Phone',
review: 'Review Setup',
saveFailed: 'Setup could not be saved. Try again.',
saving: 'Saving setup',
},
},
Common: {
@@ -5218,13 +5225,13 @@ export const usePhoneStore = defineStore('phone', {
JSON.stringify(device.data.settings?.payload ?? null),
)
},
saveDeviceNamespace(namespace: string, payload: unknown): void {
saveDeviceNamespace(namespace: string, payload: unknown): Promise<boolean> {
const imei = this.device?.imei
if (!imei) {
console.error(
`[Phone persistence] Could not save ${namespace} without an active device.`,
)
return
return Promise.resolve(false)
}
const generation = this.persistenceGeneration
const session = this.persistenceSession
@@ -5238,7 +5245,7 @@ export const usePhoneStore = defineStore('phone', {
this.deviceSessionToken === token
const previous = namespaceQueues.get(queueKey) ?? Promise.resolve()
const queued = previous.then(async () => {
if (!isCurrentScope()) return
if (!isCurrentScope()) return false
const response = await nuiCall<{ revision: number }>('device:save', {
imei,
namespace,
@@ -5253,13 +5260,21 @@ export const usePhoneStore = defineStore('phone', {
Number(response.data?.revision) >= 0
) {
this.deviceRevisions[namespace] = Number(response.data?.revision)
return true
}
if (isCurrentScope()) {
console.error(
`[Phone persistence] Could not save ${namespace}: ${response.error ?? 'request_failed'}`,
)
}
return false
})
const tracked = queued.finally(() => {
if (namespaceQueues.get(queueKey) === tracked)
namespaceQueues.delete(queueKey)
})
namespaceQueues.set(queueKey, tracked)
return tracked
},
async flushDevicePersistence(): Promise<void> {
const imei = this.device?.imei
@@ -5324,10 +5339,16 @@ export const usePhoneStore = defineStore('phone', {
)
this.saveDeviceNamespace('settings', this.preferences)
},
completeSetup(): void {
this.preferences.settings.setupCompleted = true
this.preferences.settings.setupStep = PHONE_SETUP_LAST_STEP
this.saveDeviceNamespace('settings', this.preferences)
async completeSetup(): Promise<boolean> {
const completedPreferences = cloneJsonData(this.preferences)
completedPreferences.settings.setupCompleted = true
completedPreferences.settings.setupStep = PHONE_SETUP_LAST_STEP
const saved = await this.saveDeviceNamespace(
'settings',
completedPreferences,
)
if (saved) this.preferences = completedPreferences
return saved
},
resetAfterFactoryReset(): void {
this.persistenceGeneration += 1
+69
View File
@@ -0,0 +1,69 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const readResourceFile = (path: string) =>
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
const config = readResourceFile('config/config.lua')
const testData = readResourceFile('source/server/testdata.lua')
describe('test data seeding contracts', () => {
it('keeps test data disabled while retaining the development phone command', () => {
expect(config).toContain('DevelopmentCommand = true')
expect(config).toContain(
'Enabled = false, -- development/test servers only',
)
})
it('returns before registering the test-data command when disabled', () => {
expect(
testData.indexOf('if not Config.TestData.Enabled then'),
).toBeLessThan(testData.indexOf('RegisterCommand(Config.TestData.Command'))
})
it('moves an existing player SIM before attaching it to the selected phone', () => {
expect(testData).toContain('local function move_sim_to_device')
expect(testData).toContain(
'SET `sim_id` = NULL WHERE `sim_id` = ? AND `imei` <> ?',
)
expect(testData).toContain(
'SET `sim_id` = ? WHERE `imei` = ? AND `sim_id` IS NULL',
)
expect(testData).toContain(
'local previous_imei = move_sim_to_device(sim.id, imei)',
)
expect(testData).toContain(
'restore_sim_attachment(sim.id, imei, previous_imei)',
)
})
it('derives distinct DarkChat identifiers from each account', () => {
expect(testData).toContain(
'local function darkchat_identifiers(account_id)',
)
expect(testData).toContain(
'local user_dark_id, user_invite_code = darkchat_identifiers(account_id)',
)
expect(testData).toContain(
'local bot_dark_id, bot_invite_code = darkchat_identifiers(bot_id)',
)
expect(testData).not.toContain("'DARK0000000001'")
expect(testData).not.toContain("'INV00000001'")
})
it('loads the persisted Flare match before inserting its test message', () => {
const matchLookup = testData.indexOf(
'SELECT `id` FROM `sky_phone_flare_matches`',
)
const messageInsert = testData.indexOf(
'INSERT INTO `sky_phone_flare_messages`',
)
expect(matchLookup).toBeGreaterThan(-1)
expect(messageInsert).toBeGreaterThan(matchLookup)
expect(testData).toContain(
'stable_uuid("sky_phone:testdata:flare:message:" .. match_id)',
)
})
})
+2 -1
View File
@@ -3,6 +3,7 @@ export type HousingAccess = 'owner' | 'keyholder'
export type HousingCapabilities = {
cctv: boolean
garageStatus: boolean
keyGrant?: boolean
keys: boolean
lock: boolean
waypoint: boolean
@@ -24,7 +25,7 @@ export type HousingProperty = {
access: HousingAccess
capabilities: HousingCapabilities
cctv: { enabled: boolean }
entrance: { x: number; y: number; z: number }
entrance?: { x: number; y: number; z: number }
garage: { enabled: boolean; storedVehicles: number } | null
id: string
keys?: HousingKey[]
+2
View File
@@ -48,6 +48,8 @@ export type PhoneCall = {
device?: { imei: string; name: string }
direction: CallDirection
id: string
muted?: boolean
muteSupported?: boolean
otherNumber: string
speakerEnabled?: boolean
speakerSupported?: boolean
@@ -169,6 +169,9 @@ describe('Sky UI Konsta 5.3 parity contracts', () => {
expect(html).toContain('id="directory-search"')
expect(html).toContain('sky-glass')
expect(html).not.toContain('sky-glass--highlight')
expect(html).toContain('<svg class="sky-searchbar__icon"')
expect(html).toContain('fill-rule="evenodd"')
expect(html).not.toContain('<circle')
expect(html).toContain('sky-searchbar__clear')
expect(html).toContain('aria-label="Clear query"')
expect(html).toContain('sky-searchbar__disable')
+6 -19
View File
@@ -1465,26 +1465,13 @@ label.sky-list-item__row {
}
.sky-searchbar__icon {
width: 14px;
height: 14px;
position: relative;
width: 18px;
height: 18px;
display: block;
flex: none;
border: 1.6px solid var(--sky-muted, rgba(0, 0, 0, 0.55));
border-radius: 50%;
opacity: 0.8;
}
.sky-searchbar__icon::after {
width: 6px;
height: 1.6px;
position: absolute;
right: -5px;
bottom: -2px;
content: '';
border-radius: 2px;
background: var(--sky-muted, rgba(0, 0, 0, 0.55));
transform: rotate(45deg);
transform-origin: left center;
color: var(--sky-muted, rgba(0, 0, 0, 0.55));
fill: currentColor;
opacity: 0.72;
}
.sky-searchbar__input {
+7 -1
View File
@@ -141,7 +141,13 @@ function disable(event: MouseEvent): void {
<label v-if="label" class="sky-visually-hidden" :for="resolvedInputId">
{{ label }}
</label>
<span class="sky-searchbar__icon" aria-hidden="true" />
<svg class="sky-searchbar__icon" aria-hidden="true" viewBox="0 0 24 24">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M9.5 3a6.5 6.5 0 1 0 3.98 11.64l4.44 4.44a1 1 0 0 0 1.42-1.42l-4.44-4.44A6.5 6.5 0 0 0 9.5 3Zm0 2a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9Z"
/>
</svg>
<input
:id="resolvedInputId"
ref="input"
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { isTextInputElement } from '@/utils/textInputFocus'
function element(
tagName: string,
attributes: Record<string, string> = {},
isContentEditable = false,
) {
return {
getAttribute(name: string) {
return attributes[name] ?? null
},
isContentEditable,
tagName,
}
}
describe('text input focus', () => {
it('recognizes fields that accept typed text', () => {
expect(isTextInputElement(element('INPUT'))).toBe(true)
expect(isTextInputElement(element('INPUT', { type: 'number' }))).toBe(true)
expect(isTextInputElement(element('TEXTAREA'))).toBe(true)
expect(isTextInputElement(element('DIV', {}, true))).toBe(true)
expect(isTextInputElement(element('DIV', { role: 'textbox' }))).toBe(true)
})
it('ignores non-text and read-only controls', () => {
expect(isTextInputElement(element('INPUT', { type: 'checkbox' }))).toBe(
false,
)
expect(isTextInputElement(element('INPUT', { type: 'range' }))).toBe(false)
expect(isTextInputElement(element('INPUT', { readonly: '' }))).toBe(false)
expect(isTextInputElement(element('BUTTON'))).toBe(false)
})
})
+34
View File
@@ -0,0 +1,34 @@
const nonTextInputTypes = new Set([
'button',
'checkbox',
'color',
'file',
'hidden',
'image',
'radio',
'range',
'reset',
'submit',
])
type FocusableElement = Pick<
HTMLElement,
'getAttribute' | 'isContentEditable' | 'tagName'
>
export function isTextInputElement(element: FocusableElement): boolean {
if (
element.getAttribute('readonly') !== null ||
element.getAttribute('aria-readonly') === 'true'
) {
return false
}
if (element.isContentEditable || element.getAttribute('role') === 'textbox') {
return true
}
if (element.tagName === 'TEXTAREA') return true
if (element.tagName !== 'INPUT') return false
const inputType = element.getAttribute('type')?.toLowerCase() ?? 'text'
return !nonTextInputTypes.has(inputType)
}
@@ -19,7 +19,17 @@ const mainCss = readFileSync(
new URL('../assets/main.css', import.meta.url),
'utf8',
)
const builtInWallpaperCss = mainCss.slice(
mainCss.indexOf('.wallpaper--midnight'),
mainCss.indexOf('.wallpaper--custom'),
)
describe('Springboard page swipe contract', () => {
it('keeps the built-in wallpapers visually restrained', () => {
expect(builtInWallpaperCss).not.toMatch(/(?:conic|repeating-\w+)-gradient/)
expect(builtInWallpaperCss.match(/radial-gradient/g)).toHaveLength(12)
expect(builtInWallpaperCss.match(/linear-gradient/g)).toHaveLength(12)
})
it('replaces the home status row with the edit controls', () => {
expect(viewSource).toContain("emit('editModeChange', editing)")
expect(viewSource).toContain("emit('editModeChange', false)")
@@ -22,7 +22,7 @@ describe('phone app theme contract', () => {
it('provides the reactive phone theme to every routed app', () => {
expect(appShellSource).toContain('import { SkyProvider }')
expect(appShellSource).toContain('class="phone-app-theme"')
expect(appShellSource).toContain(':dark="phone.isDarkMode"')
expect(appShellSource).toContain(':dark="displayedDarkMode"')
expect(appShellSource).toMatch(
/<SkyProvider[\s\S]*?<RouterView[\s\S]*?<component/,
)
@@ -44,6 +44,15 @@ describe('House app sheets', () => {
)
})
it('can expose key revocation without advertising key grants', () => {
expect(source).toContain(
'v-if="selectedProperty.capabilities.keyGrant !== false"',
)
expect(source).toContain(
'<section v-if="selectedProperty.capabilities.keys" class="house-keys">',
)
})
it('centers the resident icon above the Give a Key title', () => {
expect(source).toMatch(
/\.house-candidates > svg\s*\{[^}]*display:\s*block;[^}]*margin:\s*0 auto;/s,
+1
View File
@@ -464,6 +464,7 @@ onBeforeUnmount(() => {
><strong>{{ phone.t('Apps.house.keys') }}</strong></span
>
<sky-button
v-if="selectedProperty.capabilities.keyGrant !== false"
rounded
small
inline
+35 -3
View File
@@ -100,7 +100,7 @@ const inCallKeypad = ref('')
const blockDialogOpened = ref(false)
const blockTargetNumber = ref('')
const callSpeakerPending = ref(false)
const callMuted = ref(false)
const callMutePending = ref(false)
const callElapsedSeconds = ref(0)
let callClock: number | null = null
const tabs = [
@@ -464,6 +464,26 @@ async function toggleCallSpeaker(): Promise<void> {
}
}
async function toggleCallMute(): Promise<void> {
const call = calls.activeCall
if (
!call ||
call.state !== 'connected' ||
!call.muteSupported ||
callMutePending.value
) {
return
}
error.value = ''
callMutePending.value = true
const response = await calls.setMuted(!call.muted)
callMutePending.value = false
if (!response.success) {
error.value = phone.t(`Apps.phone.errors.${response.error ?? 'default'}`)
}
}
function updateCallElapsed(): void {
const call = calls.activeCall
if (!call || call.state !== 'connected') {
@@ -853,8 +873,20 @@ onBeforeUnmount(() => {
<sky-button
rounded
class="phone-call-action"
:class="{ 'is-active': callMuted }"
@click="callMuted = !callMuted"
:class="{
'is-active': calls.activeCall.muted,
'is-disabled':
calls.activeCall.state !== 'connected' ||
!calls.activeCall.muteSupported,
}"
:disabled="
calls.activeCall.state !== 'connected' ||
!calls.activeCall.muteSupported ||
callMutePending
"
:aria-busy="callMutePending || undefined"
:aria-pressed="calls.activeCall.muted === true"
@click="toggleCallMute"
>
<MicOff />
<span>{{ phone.t('Apps.phone.mute') }}</span>
@@ -13,6 +13,10 @@ describe('SettingsApp Sky UI contract', () => {
expect(source).not.toMatch(/<\/?k-[a-z]/)
expect(source).toContain('<SkyAppPage')
expect(source).toContain('<SkyNavbar')
expect(source).toContain('class="settings-navbar"')
expect(source).toContain(
'.settings-navbar.sky-navbar--large.sky-navbar--no-navigation',
)
expect(source).toContain(
":variant=\"activeView === 'root' ? 'large' : 'compact'\"",
)
@@ -64,4 +68,10 @@ describe('SettingsApp Sky UI contract', () => {
expect(source).toContain("phone.t('Apps.settings.keepCloudData')")
expect(source).toContain('phone.resetAfterFactoryReset()')
})
it('provides a non-destructive development preview for the reset progress screen', () => {
expect(source).toContain('import.meta.env.DEV')
expect(source).toContain("has('factoryResetPreview')")
expect(source).toContain('factoryResetProgress.value = 46')
})
})
+116 -10
View File
@@ -740,6 +740,14 @@ async function confirmSimEject(): Promise<void> {
}
onMounted(() => {
if (
import.meta.env.DEV &&
new URLSearchParams(window.location.search).has('factoryResetPreview')
) {
factoryResetting.value = true
factoryResetProgress.value = 46
}
if (route.query.wallpaper === '1') {
activeView.value = 'wallpaper'
wallpaperTarget.value =
@@ -771,6 +779,7 @@ onBeforeUnmount(() => {
:label="phone.t('Apps.settings.name')"
>
<SkyNavbar
class="settings-navbar"
:title="
activeView === 'root' ? phone.t('Apps.settings.name') : activeTitle
"
@@ -1699,17 +1708,8 @@ onBeforeUnmount(() => {
>
<div class="settings-reset-content">
<div class="settings-reset-mark" aria-hidden="true">
<span
class="settings-reset-mark__layer settings-reset-mark__layer--back"
></span>
<span
class="settings-reset-mark__layer settings-reset-mark__layer--middle"
></span>
<div class="settings-reset-mark__face">
<Smartphone :size="35" :stroke-width="1.45" />
<span class="settings-reset-mark__erase">
<i></i><i></i><i></i>
</span>
<Smartphone :size="38" :stroke-width="1.45" />
</div>
</div>
@@ -1872,6 +1872,16 @@ onBeforeUnmount(() => {
</template>
<style scoped>
:deep(.settings-navbar.sky-navbar--large) {
min-height: calc(
var(--sky-navbar-safe-area-top) + var(--sky-navbar-large-title-height)
);
}
:deep(.settings-navbar.sky-navbar--large.sky-navbar--no-navigation) {
padding-top: calc(var(--sky-navbar-safe-area-top) + var(--sky-space-3));
}
.settings-search {
margin-bottom: var(--sky-space-4);
}
@@ -2510,6 +2520,102 @@ onBeforeUnmount(() => {
line-height: 14px;
}
/* Factory reset stays intentionally quiet: white, direct and system-like. */
.settings-reset-hero__icon {
border: 0;
border-radius: 50%;
color: #ff3b30;
background: #f2f2f7;
box-shadow: none;
}
.settings-reset-overlay {
padding: 58px 30px 34px;
color: #1d1d1f;
background: #ffffff;
}
.settings-reset-overlay::before,
.settings-reset-overlay::after {
content: none;
}
.settings-reset-content {
max-width: 300px;
}
.settings-reset-mark {
display: grid;
width: 88px;
height: 88px;
place-items: center;
border-radius: 50%;
background: #f2f2f7;
}
.settings-reset-mark__face {
position: static;
display: grid;
width: 88px;
height: 88px;
border: 0;
border-radius: 50%;
color: #ff3b30;
background: transparent;
box-shadow: none;
place-items: center;
}
.settings-reset-heading {
margin-top: 28px;
}
.settings-reset-heading h2 {
color: #1d1d1f;
font-size: 24px;
font-weight: 700;
letter-spacing: -0.04em;
}
.settings-reset-heading p {
color: #6e6e73;
}
.settings-reset-progress-copy {
margin-top: 40px;
}
.settings-reset-progress-copy strong {
color: #1d1d1f;
font-size: 15px;
}
.settings-reset-progress-copy span {
color: #8e8e93;
}
.settings-reset-progress {
height: 5px;
background: #e5e5ea;
}
.settings-reset-progress > span {
background: #007aff;
box-shadow: none;
}
.settings-reset-detail {
color: #8e8e93;
}
.settings-reset-assurance {
margin-top: 34px;
padding: 14px;
border: 0;
border-radius: 16px;
background: #f2f2f7;
box-shadow: none;
}
.settings-reset-assurance__icon {
border-radius: 50%;
color: #007aff;
background: #e4f1ff;
}
.settings-reset-assurance strong {
color: #1d1d1f;
}
.settings-reset-assurance small {
color: #6e6e73;
}
.settings-reset-warning {
color: #8e8e93;
}
.settings-dialog-button--danger:not(:disabled) {
background: var(--sky-danger);
color: #ffffff;
+37 -2
View File
@@ -22,6 +22,10 @@ const clientMain = readFileSync(
new URL('../../sky_phone/source/client/main.lua', import.meta.url),
'utf8',
)
const phoneApp = readFileSync(
new URL('./views/apps/PhoneApp.vue', import.meta.url),
'utf8',
)
const clientRadio = readFileSync(
new URL('../../sky_phone/source/bridge/client/radio.lua', import.meta.url),
'utf8',
@@ -76,13 +80,13 @@ describe('voice provider contracts', () => {
expect(config).toMatch(/Config\.Speaker\s*=\s*\{\s*Enabled\s*=\s*true,/)
expect(sharedBridge).toContain('function Bridge.Speaker.IsEnabled()')
expect(clientCalls).toContain(
'Bridge.Speaker.IsEnabled() and resolve_provider() == "saltychat"',
'Bridge.Speaker.IsEnabled() and (selected == "yaca" or selected == "saltychat")',
)
expect(clientRadio).toContain(
'Bridge.Speaker.IsEnabled() and resolve_provider() == "saltychat"',
)
expect(serverVoice).toContain(
'Bridge.Speaker.IsEnabled() and resolve_call_provider() == "saltychat"',
'Bridge.Speaker.IsEnabled() and (selected == "yaca" or selected == "saltychat")',
)
expect(serverVoice).toContain(
'Bridge.Speaker.IsEnabled() and resolve_radio_provider() == "saltychat"',
@@ -90,6 +94,37 @@ describe('voice provider contracts', () => {
expect(serverCalls).toContain('if not Bridge.Speaker.IsEnabled() then')
})
it('integrates Yaca calls, speaker mode and provider-backed mute end to end', () => {
expect(config).toContain('yaca (alias: yaca-voice)')
expect(clientCalls).toContain('yaca = "yaca-voice"')
expect(clientCalls).toContain(
'Yaca and SaltyChat call membership is owned by the server bridge.',
)
expect(serverVoice).toContain(
'exports["yaca-voice"]:callPlayer(caller_source, target_source, true)',
)
expect(serverVoice).toContain(
'exports["yaca-voice"]:callPlayer(caller_source, target_source, false)',
)
expect(serverVoice).toContain('exports["yaca-voice"]:enablePhoneSpeaker(')
expect(serverVoice).toContain('exports["yaca-voice"]:muteOnPhone(')
expect(serverCalls).toContain(
'Bridge.Callbacks.Register("sky_phone:calls:set-muted"',
)
expect(clientMain).toContain('"calls:set-muted"')
expect(phoneApp).toContain('@click="toggleCallMute"')
expect(phoneApp).not.toContain('callMuted = !callMuted')
})
it('passes Yaca radio volume arguments in the documented order', () => {
expect(clientRadio).toContain(
'changeRadioChannelVolumeRaw(volume / 100, 1)',
)
expect(clientRadio).toContain(
'changeRadioChannelVolumeRaw(volume / 100, 2)',
)
})
it('provides safe shared defaults for the optional server radio speaker adapter', () => {
expect(sharedBridge).toContain('function Bridge.Radio.SupportsSpeaker()')
expect(sharedBridge).toMatch(
+11
View File
@@ -22,6 +22,7 @@ const lifecycleEndpoints = new Set([
'device:notification-open',
'notification:focus',
'sim:picker-close',
'ui:input-focus',
'ui:opened',
'ui:ready',
])
@@ -10407,6 +10408,16 @@ app.post('/api/:endpoint', (request, response) => {
mockPasscode = ''
mockSecurity = { enabled: false, length: null, lockedUntil: 0 }
for (const key of Object.keys(deviceData)) delete deviceData[key]
Object.assign(deviceData, {
apps: { payload: { claimedApps: [] }, revision: 0 },
settings: {
payload: {
settings: { setupCompleted: false, setupStep: 0 },
version: 1,
},
revision: 0,
},
})
response.json({ success: true })
return
}
+14
View File
@@ -1156,6 +1156,7 @@ async function main() {
'device:notification-open',
'notification:focus',
'sim:picker-close',
'ui:input-focus',
'ui:opened',
'ui:ready',
]
@@ -1163,6 +1164,19 @@ async function main() {
await expectSuccess(baseUrl, endpoint)
}
await expectSuccess(baseUrl, 'device:factory-reset')
const resetBootstrap = await expectSuccess(
baseUrl,
'development:bootstrap',
{ _testScenario: 'setupPreview' },
true,
)
assert.equal(
resetBootstrap.device.data.settings.payload.settings.setupCompleted,
false,
'factory reset did not restore a browser-testable setup state',
)
const unknown = await post(baseUrl, 'development:missing-mock', {})
assert.deepEqual(unknown, {
error: 'mock_endpoint_missing',
+6 -4
View File
@@ -89,9 +89,9 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
| --- | --- |
| **Frameworks** | ESX Legacy, QBCore, Qbox |
| **Inventories** | ox_inventory, qb-inventory, lj-inventory, qs-inventory, codem-inventory, core_inventory, mf-inventory, smx-inventory, hex_4_inventory, and native ESX inventory |
| **Calls** | PMA Voice, SaltyChat |
| **Calls** | YACA, PMA Voice, SaltyChat |
| **Radio** | YACA, PMA Voice, SaltyChat |
| **Housing** | ESX Property, qbx_properties |
| **Housing** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
| **Garages** | Built-in/custom data and a broad set of popular garage providers configured through the bridge |
| **Custom app contracts** | Sky Phone, LB Phone, 17Movement, High Phone, Quasar Smartphone, YSeries |
| **Languages** | English, German |
@@ -140,6 +140,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
Phone calls support:
- YACA
- PMA Voice
- SaltyChat
@@ -433,10 +434,11 @@ Config.Calls.VoiceProvider = "pma"
Supported values:
- `yaca` or `yaca-voice`
- `pma` or `pma-voice`
- `saltychat` or `salty`
SaltyChat supports the provider-backed call speaker feature. PMA Voice keeps the speaker option unavailable.
YACA supports calls, payphone calls, provider-backed speaker mode, and real microphone mute. SaltyChat supports provider-backed speaker mode. PMA Voice keeps speaker and mute controls unavailable.
### Radio
@@ -523,7 +525,7 @@ Select the provider under `Config.Garage.System`. Vehicle images use the configu
### Housing
Select the provider under `Config.Housing.System`. Automatic mode supports the configured provider priority.
Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API.
### Companies
+4 -4
View File
@@ -34,7 +34,7 @@ Config.Phone = {
}
Config.TestData = {
Enabled = true,
Enabled = false, -- development/test servers only; keep disabled in production
Command = "phonetestdata",
AdminOnly = false, -- enable only on development servers; every run is scoped to the executing player's phone
AdminGroups = { "admin", "superadmin" },
@@ -82,7 +82,7 @@ Config.Speaker = {
}
Config.Calls = {
VoiceProvider = "pma", -- pma (alias: pma-voice), saltychat (alias: salty)
VoiceProvider = "pma", -- yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
RingSeconds = 30,
ContactNameMaxLength = 80,
ContactNotesMaxLength = 500,
@@ -368,8 +368,8 @@ Config.Garage = {
}
Config.Housing = {
System = "auto", -- auto, esx_property, qbx_properties
AutoPriority = { "esx_property", "qbx_properties" },
System = "auto", -- auto, rtx, quasar, vms, rx, nolag, sn, esx_property, qbx_properties
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "vms", "rx", "nolag", "sn" },
MaximumProperties = 50,
OverviewRequestsPerMinute = 30,
ActionsPerMinute = 12,
+3 -1
View File
@@ -123,6 +123,7 @@ Locales["de"] = {
ready = {
eyebrow = "Einrichtung abgeschlossen", title = "Willkommen, {name}", body = "Dein Sky Phone ist eingerichtet und bereit. Du kannst alles später in den Einstellungen anpassen.",
localOnly = "Auf diesem Handy gespeichert", enter = "Sky Phone öffnen", review = "Einrichtung prüfen",
saving = "Einrichtung wird gespeichert", saveFailed = "Die Einrichtung konnte nicht gespeichert werden. Versuche es erneut.",
},
},
Common = {
@@ -1008,8 +1009,9 @@ Locales["de"] = {
message_unavailable = "Das Gespräch konnte nicht eröffnet werden.", contact_remove_failed = "Der Kontakt konnte nicht entfernt werden.", contact_favorite_failed = "Der Favorit konnte nicht aktualisiert werden.",
blocked = "Diese Nummer ist blockiert.", recipient_not_found = "Diese Nummer ist nicht bekannt.",
rate_limited = "Zu viele Anrufe. Versuch es in einer Minute erneut.", voice_unavailable = "Der konfigurierte Sprachdienst ist nicht verfügbar.",
call_not_connected = "Schließ den Anruf an, bevor du den Lautsprechermodus ermöglichst.", speaker_unavailable = "Der Lautsprechermodus ist für den konfigurierten Handy-Sprachdienst nicht verfügbar.",
call_not_connected = "Nimm den Anruf an, bevor du die Audiosteuerung änderst.", speaker_unavailable = "Der Lautsprechermodus ist für den konfigurierten Handy-Sprachdienst nicht verfügbar.",
speaker_unsupported = "Der konfigurierte Handy-Sprachdienst unterstützt den Lautsprechermodus nicht.",
mute_unavailable = "Die Stummschaltung ist für den konfigurierten Handy-Sprachdienst nicht verfügbar.", mute_unsupported = "Der konfigurierte Handy-Sprachdienst unterstützt keine Stummschaltung.",
inventory_full = "Es gibt keinen Platz für die ausgeworfene SIM-Karte.", request_failed = "Die Telefonanfrage ist fehlgeschlagen.",
operation_in_progress = "Eine andere Handy-Aktion wird bereits ausgeführt.", sim_request_expired = "Die SIM-Auswahl ist abgelaufen. Verwende die SIM-Karte erneut.",
sim_not_owned = "Diese SIM Karte ist nicht mehr in deinem Inventar.", phone_not_owned = "Das Handy ist nicht mehr in deinem Inventar.",
+3 -1
View File
@@ -123,6 +123,7 @@ Locales["en"] = {
ready = {
eyebrow = "Setup Complete", title = "Welcome, {name}", body = "Your Sky Phone is configured and ready. Your choices can always be refined in Settings.",
localOnly = "Stored on this phone", enter = "Enter Sky Phone", review = "Review Setup",
saving = "Saving setup", saveFailed = "Setup could not be saved. Try again.",
},
},
Common = {
@@ -1008,8 +1009,9 @@ Locales["en"] = {
message_unavailable = "The conversation could not be opened.", contact_remove_failed = "The contact could not be removed.", contact_favorite_failed = "The favorite could not be updated.",
blocked = "This number is blocked.", recipient_not_found = "This number is not known.",
rate_limited = "Too many calls. Try again in a minute.", voice_unavailable = "The configured phone voice service is unavailable.",
call_not_connected = "Connect the call before enabling speaker mode.", speaker_unavailable = "Speaker mode is not available for the configured phone voice service.",
call_not_connected = "Connect the call before changing its audio controls.", speaker_unavailable = "Speaker mode is not available for the configured phone voice service.",
speaker_unsupported = "The configured phone voice service does not support speaker mode.",
mute_unavailable = "Mute is not available for the configured phone voice service.", mute_unsupported = "The configured phone voice service does not support mute.",
inventory_full = "There is no room for the ejected SIM card.", request_failed = "The phone request failed.",
operation_in_progress = "Another phone operation is already in progress.", sim_request_expired = "The SIM selection expired. Use the SIM card again.",
sim_not_owned = "That SIM card is no longer in your inventory.", phone_not_owned = "That phone is no longer in your inventory.",
+1
View File
@@ -55,6 +55,7 @@ server_scripts {
'config/media.lua',
'config/locales/en.lua',
'config/locales/de.lua',
'source/server/update_check.lua',
'source/bridge/server/database.lua',
'source/bridge/server/migrations.lua',
'source/bridge/server/callbacks.lua',
+10 -3
View File
@@ -1,8 +1,10 @@
local provider_resources = {
yaca = "yaca-voice",
pma = "pma-voice",
saltychat = "saltychat",
}
local provider_aliases = {
["yaca-voice"] = "yaca",
["pma-voice"] = "pma",
salty = "saltychat",
}
@@ -22,7 +24,12 @@ function Bridge.Calls.GetProvider()
end
function Bridge.Calls.SupportsSpeaker()
return Bridge.Speaker.IsEnabled() and resolve_provider() == "saltychat"
local selected = resolve_provider()
return Bridge.Speaker.IsEnabled() and (selected == "yaca" or selected == "saltychat")
end
function Bridge.Calls.SupportsMute()
return resolve_provider() == "yaca"
end
function Bridge.Calls.Join(channel)
@@ -37,8 +44,8 @@ function Bridge.Calls.Join(channel)
return true
end
if selected == "saltychat" then
-- SaltyChat call membership is owned by the server bridge.
if selected == "yaca" or selected == "saltychat" then
-- Yaca and SaltyChat call membership is owned by the server bridge.
return true
end
@@ -5,10 +5,67 @@ Bridge.Housing = {
function Bridge.Housing.RegisterClientProvider(name, provider)
assert(type(name) == "string" and name ~= "", "Housing provider name must be a non-empty string")
assert(type(provider) == "table" and type(provider.execute) == "function", "Housing client provider must implement execute")
assert(
provider.enrich_overview == nil or type(provider.enrich_overview) == "function",
"Housing client provider enrich_overview must be a function"
)
assert(not Bridge.Housing.ClientProviders[name], ("Housing client provider '%s' is already registered"):format(name))
Bridge.Housing.ClientProviders[name] = provider
end
function Bridge.Housing.EnrichOverview(provider_name, properties)
if type(properties) ~= "table" then
return nil, "invalid_overview"
end
local provider = Bridge.Housing.ClientProviders[provider_name]
if not provider or type(provider.enrich_overview) ~= "function" then
return properties
end
local success, enriched, error_code = pcall(provider.enrich_overview, properties)
if not success then
Bridge.Debug(
"error",
"[sky_phone] Housing provider '%s' overview enrichment failed: %s",
tostring(provider_name),
tostring(enriched)
)
return properties
end
if type(enriched) ~= "table" then
Bridge.Debug(
"error",
"[sky_phone] Housing provider '%s' returned invalid overview enrichment: %s",
tostring(provider_name),
tostring(error_code or enriched)
)
return properties
end
local entrances = {}
for _, property in ipairs(enriched) do
if type(property) == "table" and property.id ~= nil and entrances[property.id] == nil then
entrances[property.id] = Bridge.Normalize.Coordinates(property.entrance)
end
end
local result = {}
for _, property in ipairs(properties) do
local entrance = type(property) == "table" and entrances[property.id] or nil
if entrance then
local normalized = {}
for key, value in pairs(property) do
normalized[key] = value
end
normalized.entrance = entrance
result[#result + 1] = normalized
else
result[#result + 1] = property
end
end
return result
end
function Bridge.Housing.Execute(provider_name, action, data)
local provider = Bridge.Housing.ClientProviders[provider_name]
if not provider then
@@ -0,0 +1,27 @@
local resource_name = "nolag_properties"
Bridge.Housing.RegisterClientProvider("nolag", {
execute = function(action, data)
if action ~= "grant_key" and action ~= "revoke_key" and action ~= "toggle_lock" then
return false, "capability_unavailable"
end
if GetResourceState(resource_name) ~= "started" then
return false, "provider_unavailable"
end
if type(data) ~= "table" then
return false, "invalid_request"
end
local result = Bridge.Callbacks.Trigger("sky_phone:housing:nolag:execute", {
action = action,
propertyId = data.propertyId,
providerId = data.providerId,
target = data.target,
identifier = data.identifier,
})
if type(result) == "table" and result.success then
return true
end
return false, type(result) == "table" and result.error or "provider_rejected"
end,
})
@@ -0,0 +1,156 @@
local provider_name = "quasar"
local resource_name = "qs-housing"
local function table_like(value)
local value_type = type(value)
return value_type == "table" or value_type == "vector3" or value_type == "vector4"
end
local function field(value, key)
if not table_like(value) then
return nil
end
local success, result = pcall(function()
return value[key]
end)
return success and result or nil
end
local function decode_object(value)
if table_like(value) then
return value
end
if type(value) ~= "string" or value == "" then
return nil
end
local success, decoded = pcall(json.decode, value)
return success and table_like(decoded) and decoded or nil
end
local function normalized_coords(value)
value = decode_object(value)
return value and Bridge.Normalize.Coordinates(value) or nil
end
local function property_tables(value, house)
value = decode_object(value)
if not value then
return {}
end
local result = { value }
local named = house and decode_object(field(value, house)) or nil
if named and named ~= value then
result[#result + 1] = named
end
for _, key in ipairs({ "data", "houseData", "house_data", "propertyData", "property_data" }) do
local nested = decode_object(field(value, key))
if nested and nested ~= value and nested ~= named then
result[#result + 1] = nested
end
end
return result
end
local function entrance_for(value, house)
for _, candidate in ipairs(property_tables(value, house)) do
for _, key in ipairs({ "entrance", "Entrance", "entry", "Entry", "enter", "Enter" }) do
local coords = normalized_coords(field(candidate, key))
if coords then
return coords
end
end
for _, key in ipairs({ "coords", "coordinates", "location", "position", "points" }) do
local container = decode_object(field(candidate, key))
if container then
for _, nested_key in ipairs({
"entrance", "Entrance", "entry", "Entry", "enter", "Enter", "door", "frontDoor",
}) do
local coords = normalized_coords(field(container, nested_key))
if coords then
return coords
end
end
local coords = normalized_coords(container)
if coords then
return coords
end
end
end
end
return nil
end
local function house_reference(value)
if type(value) ~= "string" and type(value) ~= "number" then
return nil
end
local house = tostring(value):match("^%s*(.-)%s*$")
return house ~= "" and house or nil
end
local function house_entrance(house)
local success, house_data = pcall(function()
return exports["qs-housing"]:getHouseData(house)
end)
if not success then
Bridge.Debug(
"error",
"[sky_phone] qs-housing:getHouseData failed for '%s': %s",
house,
tostring(house_data)
)
return nil, "provider_error"
end
local entrance = entrance_for(house_data, house)
if not entrance then
return nil, "invalid_coordinates"
end
return entrance
end
local function enrich_overview(properties)
local result = {}
if GetResourceState(resource_name) ~= "started" or type(properties) ~= "table" then
return result
end
for _, property in ipairs(properties) do
local house = type(property) == "table" and house_reference(property.providerId) or nil
if house and property.id == provider_name .. ":" .. house then
local entrance = normalized_coords(property.entrance)
if not entrance then
entrance = house_entrance(house)
end
if entrance then
result[#result + 1] = {
id = property.id,
entrance = entrance,
}
end
end
end
return result
end
Bridge.Housing.RegisterClientProvider(provider_name, {
enrich_overview = enrich_overview,
execute = function(action, data)
if GetResourceState(resource_name) ~= "started" then
return false, "provider_unavailable"
end
if action ~= "set_waypoint" then
return false, "capability_unavailable"
end
local house = type(data) == "table" and house_reference(data.providerId) or nil
if not house then
return false, "invalid_property"
end
local entrance, error_code = house_entrance(house)
if not entrance then
return false, error_code
end
SetNewWaypoint(entrance.x + 0.0, entrance.y + 0.0)
return true
end,
})
@@ -0,0 +1,25 @@
local provider_name = "rtx"
local resource_name = "rtx_housing"
Bridge.Housing.RegisterClientProvider(provider_name, {
execute = function(action, data)
if action ~= "toggle_lock" then
return false, "capability_unavailable"
end
if GetResourceState(resource_name) ~= "started" then
return false, "provider_unavailable"
end
if type(data) ~= "table" or type(data.providerId) ~= "string" then
return false, "invalid_request"
end
local result = Bridge.Callbacks.Trigger("sky_phone:housing:rtx:execute", {
action = action,
providerId = data.providerId,
})
if result and result.success then
return true
end
return false, result and result.error or "provider_rejected"
end,
})
@@ -0,0 +1,27 @@
local provider_name = "rx"
local resource_name = "RxHousing"
Bridge.Housing.RegisterClientProvider(provider_name, {
execute = function(action, data)
if action ~= "grant_key" and action ~= "revoke_key" then
return false, "capability_unavailable"
end
if GetResourceState(resource_name) ~= "started" then
return false, "provider_unavailable"
end
if type(data) ~= "table" then
return false, "invalid_request"
end
local result = Bridge.Callbacks.Trigger("sky_phone:housing:rx:execute", {
action = action,
providerId = data.providerId,
target = data.target,
identifier = data.identifier,
})
if result and result.success then
return true
end
return false, result and result.error or "provider_rejected"
end,
})
@@ -0,0 +1,26 @@
local resource_name = "sn_properties"
Bridge.Housing.RegisterClientProvider("sn", {
execute = function(action, data)
if action ~= "grant_key" and action ~= "revoke_key" then
return false, "capability_unavailable"
end
if GetResourceState(resource_name) ~= "started" then
return false, "provider_unavailable"
end
if type(data) ~= "table" then
return false, "invalid_request"
end
local result = Bridge.Callbacks.Trigger("sky_phone:housing:sn:execute", {
action = action,
propertyId = data.propertyId,
providerId = data.providerId,
identifier = data.identifier,
})
if type(result) == "table" and result.success then
return true
end
return false, type(result) == "table" and result.error or "provider_rejected"
end,
})
@@ -0,0 +1,11 @@
local provider_name = "vms"
local resource_name = "vms_housing"
Bridge.Housing.RegisterClientProvider(provider_name, {
execute = function()
if GetResourceState(resource_name) ~= "started" then
return false, "provider_unavailable"
end
return false, "capability_unavailable"
end,
})
+6 -2
View File
@@ -101,6 +101,10 @@ function Bridge.Radio.Join(primary, secondary)
local selected = resolve_provider()
if selected == "yaca" then
local voice = exports["yaca-voice"]
if not voice:isEnabled() then
Bridge.Debug("error", "[sky_phone] Yaca is started but its voice system is disabled.")
return false
end
if not voice:isRadioEnabled() then
voice:enableRadio(true)
Wait(100)
@@ -150,9 +154,9 @@ end
function Bridge.Radio.SetVolume(volume)
local selected = resolve_provider()
if selected == "yaca" then
exports["yaca-voice"]:changeRadioChannelVolumeRaw(1, volume / 100)
exports["yaca-voice"]:changeRadioChannelVolumeRaw(volume / 100, 1)
if Bridge.Radio.SupportsSecondary() then
exports["yaca-voice"]:changeRadioChannelVolumeRaw(2, volume / 100)
exports["yaca-voice"]:changeRadioChannelVolumeRaw(volume / 100, 2)
end
elseif selected == "pma" then
exports["pma-voice"]:setRadioVolume(volume)
@@ -0,0 +1,510 @@
local provider_name = "nolag"
local resource_name = "nolag_properties"
local function same_identifier(left, right)
return left ~= nil and right ~= nil and tostring(left) == tostring(right)
end
local normalized_coords = Bridge.Normalize.Coordinates
local function player_source(identifier)
if identifier == nil then
return nil
end
for _, source in ipairs(Bridge.Framework.GetPlayers()) do
if same_identifier(Bridge.Framework.GetIdentifier(source), identifier) then
return source
end
end
return nil
end
local function player_name(source, fallback)
if source then
local name = Bridge.Framework.GetCharacterName(source) or GetPlayerName(source)
if type(name) == "string" then
name = name:match("^%s*(.-)%s*$")
if name ~= "" then
return name
end
end
end
return tostring(fallback)
end
local function get_properties(identifier)
local success, properties = pcall(function()
return exports[resource_name]:GetAllProperties(identifier, "user", true)
end)
if not success or type(properties) ~= "table" then
Bridge.Debug("error", "[sky_phone] nolag_properties:GetAllProperties failed: %s", tostring(properties))
return nil
end
return properties
end
local function get_property_data(property_id)
local success, property = pcall(function()
return exports[resource_name]:GetPropertyData(property_id)
end)
if not success then
Bridge.Debug("error", "[sky_phone] nolag_properties:GetPropertyData failed: %s", tostring(property))
return nil, "provider_error"
end
if type(property) ~= "table" then
return nil, "property_not_found"
end
return property
end
local function get_keyholders(property_id)
local success, keyholders = pcall(function()
return exports[resource_name]:GetKeyHolders(property_id)
end)
if not success or type(keyholders) ~= "table" then
Bridge.Debug("error", "[sky_phone] nolag_properties:GetKeyHolders failed: %s", tostring(keyholders))
return nil
end
return keyholders
end
local function get_players_in_property(property_id)
local success, players = pcall(function()
return exports[resource_name]:GetPlayersInProperty(property_id)
end)
if not success or type(players) ~= "table" then
Bridge.Debug("error", "[sky_phone] nolag_properties:GetPlayersInProperty failed: %s", tostring(players))
return nil
end
return players
end
local function property_access(property, identifier)
if same_identifier(property.owner, identifier) then
return "owner"
end
if same_identifier(property.renter, identifier) then
return "keyholder"
end
return nil
end
local function keyholder_exists(keyholders, identifier)
if type(identifier) ~= "string" or identifier == "" then
return false
end
return keyholders[identifier] ~= nil
end
local function normalized_keys(property, keyholders)
local keys = {}
for identifier in pairs(keyholders) do
if (type(identifier) == "string" or type(identifier) == "number")
and not same_identifier(identifier, property.owner)
then
local normalized_identifier = tostring(identifier)
local source = player_source(normalized_identifier)
keys[#keys + 1] = {
identifier = normalized_identifier,
name = player_name(source, normalized_identifier),
online = source ~= nil,
revocable = true,
}
end
end
table.sort(keys, function(left, right)
return string.lower(left.name) < string.lower(right.name)
end)
return keys
end
local function normalized_property(property, details, access, keyholders)
local property_id = tonumber(property.id)
local entrance = normalized_coords(property.coords)
if not property_id or property_id < 1 or property_id ~= math.floor(property_id) or not entrance then
return nil
end
local manages_keys = access == "owner"
return {
id = ("nolag:%s"):format(property_id),
providerId = tostring(property_id),
name = tostring(property.label or details.label or ("Property %s"):format(property_id)),
access = access,
locked = property.doorLocked == true,
entrance = entrance,
capabilities = {
lock = property.hasKey == true,
keys = manages_keys,
waypoint = true,
cctv = false,
garageStatus = false,
},
cctv = { enabled = false },
garage = nil,
keys = manages_keys and normalized_keys(details, keyholders) or nil,
}
end
local function find_property(properties, property_id)
for _, property in pairs(properties) do
if type(property) == "table" and tonumber(property.id) == property_id then
return property
end
end
return nil
end
local function parse_property_id(data)
if type(data) ~= "table" then
return nil, "invalid_request"
end
local property_id = type(data.propertyId) == "string"
and tonumber(data.propertyId:match("^nolag:(%d+)$")) or nil
if not property_id then
property_id = tonumber(data.providerId)
end
if not property_id or property_id < 1 or property_id ~= math.floor(property_id) then
return nil, "invalid_property"
end
return property_id
end
local function resolve_property(source, data)
local property_id, error_code = parse_property_id(data)
if not property_id then
return nil, nil, nil, nil, error_code
end
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return nil, nil, nil, nil, "housing_unavailable"
end
local properties = get_properties(identifier)
if not properties then
return nil, nil, nil, nil, "provider_error"
end
local property = find_property(properties, property_id)
if not property or not normalized_coords(property.coords) then
return nil, nil, nil, nil, "property_access_denied"
end
local details, details_error = get_property_data(property_id)
if not details then
return nil, nil, nil, nil, details_error
end
local access = property_access(details, identifier)
if not access then
return nil, nil, nil, nil, "property_access_denied"
end
return property, details, property_id, access, nil, tostring(identifier)
end
local function require_current_owner(property_id, identifier)
local details, details_error = get_property_data(property_id)
if not details then
return nil, details_error
end
if property_access(details, identifier) ~= "owner" then
return nil, "owner_required"
end
return details
end
local function get_current_access_property(identifier, property_id)
local properties = get_properties(identifier)
if not properties then
return nil, "provider_error"
end
local property = find_property(properties, property_id)
if not property then
return nil, "property_access_denied"
end
return property
end
local function key_candidates(source, property_id, details)
local players = get_players_in_property(property_id)
local keyholders = get_keyholders(property_id)
if not players or not keyholders then
return nil, "provider_error"
end
local candidates = {}
local seen = {}
for _, value in pairs(players) do
local target = tonumber(value)
local identifier = target and Bridge.Framework.GetIdentifier(target) or nil
if target and target ~= source and identifier and not seen[target]
and not same_identifier(identifier, details.owner)
and not keyholder_exists(keyholders, tostring(identifier))
then
seen[target] = true
candidates[#candidates + 1] = {
id = target,
name = player_name(target, identifier),
}
end
end
table.sort(candidates, function(left, right)
return string.lower(left.name) < string.lower(right.name)
end)
return candidates
end
local function validate_grant_target(source, property_id, details, target)
target = tonumber(target)
if not target or target == source then
return nil, "invalid_target"
end
local players = get_players_in_property(property_id)
local keyholders = get_keyholders(property_id)
if not players or not keyholders then
return nil, "provider_error"
end
local present = false
for _, value in pairs(players) do
if tonumber(value) == target then
present = true
break
end
end
if not present then
return nil, "target_not_in_property"
end
local identifier = Bridge.Framework.GetIdentifier(target)
if not identifier then
return nil, "invalid_target"
end
identifier = tostring(identifier)
if same_identifier(identifier, details.owner) or keyholder_exists(keyholders, identifier) then
return nil, "key_already_exists"
end
return identifier
end
local function execute_action(source, action, data)
if not SkyPhone.AllowOperation(source, "housing_nolag_action", Config.Housing.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
if action ~= "grant_key" and action ~= "revoke_key" and action ~= "toggle_lock" then
return { success = false, error = "invalid_action" }
end
local property, details, property_id, access, error_code, actor_identifier = resolve_property(source, data)
if not property then
return { success = false, error = error_code }
end
if (action == "grant_key" or action == "revoke_key") and access ~= "owner" then
return { success = false, error = "owner_required" }
end
if action == "toggle_lock" and property.hasKey ~= true then
return { success = false, error = "capability_unavailable" }
end
if action == "grant_key" then
local target_identifier, target_error = validate_grant_target(source, property_id, details, data.target)
if not target_identifier then
return { success = false, error = target_error }
end
local current_details, current_error = require_current_owner(property_id, actor_identifier)
if not current_details then
return { success = false, error = current_error }
end
local success, result, provider_error = pcall(function()
return exports[resource_name]:AddKey(source, property_id, target_identifier)
end)
if not success then
Bridge.Debug("error", "[sky_phone] nolag_properties:AddKey failed: %s", tostring(result))
return { success = false, error = "provider_error" }
end
local keyholders_after = get_keyholders(property_id)
if not keyholders_after then
return { success = false, error = "provider_error" }
end
if result ~= true then
return { success = false, error = provider_error or "provider_rejected" }
end
return keyholder_exists(keyholders_after, target_identifier)
and { success = true }
or { success = false, error = "action_failed" }
end
if action == "revoke_key" then
local identifier = data.identifier
if type(identifier) ~= "string" or identifier == "" or same_identifier(identifier, actor_identifier) then
return { success = false, error = "invalid_target" }
end
local keyholders = get_keyholders(property_id)
if not keyholders then
return { success = false, error = "provider_error" }
end
if not keyholder_exists(keyholders, identifier) then
return { success = false, error = "key_not_found" }
end
local current_details, current_error = require_current_owner(property_id, actor_identifier)
if not current_details then
return { success = false, error = current_error }
end
local success, result, provider_error = pcall(function()
return exports[resource_name]:RemoveKey(source, property_id, identifier)
end)
if not success then
Bridge.Debug("error", "[sky_phone] nolag_properties:RemoveKey failed: %s", tostring(result))
return { success = false, error = "provider_error" }
end
local keyholders_after = get_keyholders(property_id)
if not keyholders_after then
return { success = false, error = "provider_error" }
end
if result ~= true then
return { success = false, error = provider_error or "provider_rejected" }
end
return not keyholder_exists(keyholders_after, identifier)
and { success = true }
or { success = false, error = "action_failed" }
end
local current_property, current_error = get_current_access_property(actor_identifier, property_id)
if not current_property then
return { success = false, error = current_error }
end
if current_property.hasKey ~= true then
return { success = false, error = "capability_unavailable" }
end
local desired_state = current_property.doorLocked ~= true
local success, result, provider_error = pcall(function()
return exports[resource_name]:ToggleDoorlock(source, property_id, desired_state)
end)
if not success then
Bridge.Debug("error", "[sky_phone] nolag_properties:ToggleDoorlock failed: %s", tostring(result))
return { success = false, error = "provider_error" }
end
local details_after, details_error = get_property_data(property_id)
if not details_after then
return { success = false, error = details_error }
end
if result ~= true then
return { success = false, error = provider_error or "provider_rejected" }
end
return type(details_after.doorLocked) == "boolean" and details_after.doorLocked == desired_state
and { success = true }
or { success = false, error = "action_failed" }
end
Bridge.Housing.RegisterProvider(provider_name, {
resource_name = resource_name,
is_available = function()
return GetResourceState(resource_name) == "started"
end,
get_overview = function(source)
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return nil, "housing_unavailable"
end
identifier = tostring(identifier)
local properties = get_properties(identifier)
if not properties then
return nil, "provider_error"
end
local result = {}
for _, property in pairs(properties) do
local property_id = type(property) == "table" and tonumber(property.id) or nil
if property_id then
local details = get_property_data(property_id)
local access = details and property_access(details, identifier) or nil
local keyholders = access == "owner" and get_keyholders(property_id) or {}
local normalized = access and keyholders
and normalized_property(property, details, access, keyholders) or nil
if normalized then
result[#result + 1] = normalized
if #result >= Config.Housing.MaximumProperties then
break
end
end
end
end
table.sort(result, function(left, right)
if left.access ~= right.access then
return left.access == "owner"
end
return string.lower(left.name) < string.lower(right.name)
end)
return result
end,
prepare = function(source, action, data)
local property, details, property_id, access, error_code = resolve_property(source, data)
if not property then
return nil, error_code
end
if action == "set_waypoint" then
return { coords = normalized_coords(property.coords) }
end
if action == "open_cctv" then
return nil, "cctv_unavailable"
end
if action == "key_candidates" then
if access ~= "owner" then
return nil, "owner_required"
end
local candidates, candidates_error = key_candidates(source, property_id, details)
return candidates and { candidates = candidates } or nil, candidates_error
end
if action == "grant_key" then
if access ~= "owner" then
return nil, "owner_required"
end
local target_identifier, target_error = validate_grant_target(source, property_id, details, data.target)
if not target_identifier then
return nil, target_error
end
return { providerId = tostring(property_id), target = tonumber(data.target) }
end
if action == "revoke_key" then
if access ~= "owner" then
return nil, "owner_required"
end
local identifier = data.identifier
local keyholders = get_keyholders(property_id)
if not keyholders then
return nil, "provider_error"
end
if type(identifier) ~= "string" or not keyholder_exists(keyholders, identifier) then
return nil, "key_not_found"
end
return { providerId = tostring(property_id), identifier = identifier }
end
if action == "toggle_lock" then
if property.hasKey ~= true then
return nil, "capability_unavailable"
end
return { providerId = tostring(property_id) }
end
return nil, "invalid_action"
end,
})
Bridge.Callbacks.Register("sky_phone:housing:nolag:execute", function(source, data)
if type(data) ~= "table" or type(data.action) ~= "string" then
return { success = false, error = "invalid_request" }
end
return execute_action(source, data.action, data)
end)
@@ -0,0 +1,237 @@
local provider_name = "quasar"
local resource_name = "qs-housing"
local function table_like(value)
local value_type = type(value)
return value_type == "table" or value_type == "vector3" or value_type == "vector4"
end
local function field(value, key)
if not table_like(value) then
return nil
end
local success, result = pcall(function()
return value[key]
end)
return success and result or nil
end
local function decode_object(value)
if table_like(value) then
return value
end
if type(value) ~= "string" or value == "" then
return nil
end
local success, decoded = pcall(json.decode, value)
return success and table_like(decoded) and decoded or nil
end
local function normalized_coords(value)
value = decode_object(value)
return value and Bridge.Normalize.Coordinates(value) or nil
end
local function property_tables(property)
if not table_like(property) then
return {}
end
local result = { property }
for _, key in ipairs({ "data", "houseData", "house_data", "propertyData", "property_data" }) do
local nested = decode_object(field(property, key))
if nested and nested ~= property then
result[#result + 1] = nested
end
end
return result
end
local function entrance_for(property)
for _, candidate in ipairs(property_tables(property)) do
for _, key in ipairs({ "entrance", "Entrance", "entry", "Entry" }) do
local coords = normalized_coords(field(candidate, key))
if coords then
return coords
end
end
for _, key in ipairs({ "coords", "coordinates", "location", "position" }) do
local container = decode_object(field(candidate, key))
if container then
for _, nested_key in ipairs({ "entrance", "Entrance", "entry", "Entry", "enter", "door" }) do
local coords = normalized_coords(field(container, nested_key))
if coords then
return coords
end
end
local coords = normalized_coords(container)
if coords then
return coords
end
end
end
end
return nil
end
local function non_empty_string(value)
if type(value) ~= "string" and type(value) ~= "number" then
return nil
end
local text = tostring(value):match("^%s*(.-)%s*$")
return text ~= "" and text or nil
end
local function house_reference(key, property)
local direct = non_empty_string(property)
if direct then
return direct
end
if table_like(property) then
for _, name in ipairs({
"house", "houseName", "house_name", "name", "identifier",
}) do
local value = non_empty_string(field(property, name))
if value then
return value
end
end
end
if type(key) == "string" and not tonumber(key) then
return non_empty_string(key)
end
if table_like(property) then
for _, name in ipairs({ "propertyId", "property_id", "id" }) do
local value = non_empty_string(field(property, name))
if value then
return value
end
end
end
return nil
end
local function property_name(property, house)
for _, candidate in ipairs(property_tables(property)) do
for _, key in ipairs({ "label", "address", "adress", "displayName", "display_name", "name" }) do
local value = non_empty_string(field(candidate, key))
if value then
return value
end
end
end
return house
end
local function get_player_properties(source)
local success, properties = pcall(function()
return exports[resource_name]:GetPlayerHouses(source)
end)
if success and type(properties) == "table" then
return properties
end
Bridge.Debug(
"error",
"[sky_phone] qs-housing:GetPlayerHouses failed for source %s: %s",
tostring(source),
tostring(properties)
)
return nil, "provider_error"
end
local function normalized_properties(source)
local properties, error_code = get_player_properties(source)
if not properties then
return nil, error_code
end
local result = {}
local seen = {}
for key, property in pairs(properties) do
local house = house_reference(key, property)
if house and not seen[house] then
local entrance = entrance_for(property)
seen[house] = true
result[#result + 1] = {
id = provider_name .. ":" .. house,
providerId = house,
name = property_name(property, house),
access = "owner",
locked = false,
entrance = entrance,
capabilities = {
lock = false,
keys = false,
waypoint = true,
cctv = false,
garageStatus = false,
},
cctv = { enabled = false },
garage = nil,
keys = nil,
}
end
end
table.sort(result, function(left, right)
return string.lower(left.name) < string.lower(right.name)
end)
local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 0))
while #result > maximum do
result[#result] = nil
end
return result
end
local function find_property(source, property_id)
if type(property_id) ~= "string" then
return nil, "invalid_property"
end
local house = property_id:match("^quasar:(.+)$")
if not house or house == "" then
return nil, "invalid_property"
end
local properties, error_code = normalized_properties(source)
if not properties then
return nil, error_code
end
for _, property in ipairs(properties) do
if property.providerId == house then
return property
end
end
return nil, "property_access_denied"
end
Bridge.Housing.RegisterProvider(provider_name, {
resource_name = resource_name,
is_available = function()
return GetResourceState(resource_name) == "started"
end,
get_overview = normalized_properties,
prepare = function(source, action, data)
if action == "toggle_lock" or action == "grant_key" or action == "revoke_key"
or action == "key_candidates"
then
return nil, "capability_unavailable"
end
if action == "open_cctv" then
return nil, "cctv_unavailable"
end
if action ~= "set_waypoint" then
return nil, "invalid_action"
end
local property, error_code = find_property(source, data and data.propertyId)
if not property then
return nil, error_code
end
return {
providerId = property.providerId,
coords = property.entrance,
}
end,
})
@@ -0,0 +1,547 @@
local provider_name = "rtx"
local resource_name = "rtx_housing"
local function provider_call(export_name, callback)
local success, result = pcall(callback)
if not success then
Bridge.Debug(
"error",
"[sky_phone] %s:%s failed: %s",
resource_name,
export_name,
tostring(result)
)
return false, nil
end
return true, result
end
local function normalize_property_id(value)
local number = Bridge.Normalize.FiniteNumber(value)
if not number or number < 1 or number ~= math.floor(number) then
return nil, nil
end
local integer = math.tointeger(number)
if not integer then
return nil, nil
end
return integer, tostring(integer)
end
local function decode_table(value)
local value_type = type(value)
if value_type == "table" or value_type == "vector3" or value_type == "vector4" then
return value
end
if value_type ~= "string" or value == "" then
return nil
end
local success, decoded = pcall(json.decode, value)
return success and type(decoded) == "table" and decoded or nil
end
local function normalized_coords(value)
local coords = Bridge.Normalize.Coordinates(value)
if not coords then
return nil
end
if math.abs(coords.x) < 0.001 and math.abs(coords.y) < 0.001 and math.abs(coords.z) < 0.001 then
return nil
end
return coords
end
local function nested_coords(value)
local data = decode_table(value)
if not data then
return nil
end
return normalized_coords(data.coords) or normalized_coords(data)
end
local function property_zone_coords(property)
local zone = decode_table(property.propertyzone)
local points = zone and decode_table(zone.polyzonedata) or nil
if not points then
return nil
end
local count = 0
local x_total = 0.0
local y_total = 0.0
local z_total = 0.0
local z_count = 0
for _, point in pairs(points) do
local point_type = type(point)
if point_type == "table" or point_type == "vector3" or point_type == "vector4" then
local x = Bridge.Normalize.FiniteNumber(point.x)
local y = Bridge.Normalize.FiniteNumber(point.y)
local z = Bridge.Normalize.FiniteNumber(point.z)
if x and y then
count = count + 1
x_total = x_total + x
y_total = y_total + y
if z then
z_count = z_count + 1
z_total = z_total + z
end
end
end
end
if count == 0 then
return nil
end
local z = z_count > 0 and z_total / z_count or nil
if not z then
local minimum_z = Bridge.Normalize.FiniteNumber(zone.minz)
local maximum_z = Bridge.Normalize.FiniteNumber(zone.maxz)
if minimum_z and maximum_z then
z = (minimum_z + maximum_z) / 2.0
end
end
return normalized_coords({
x = x_total / count,
y = y_total / count,
z = z,
})
end
local function property_entrance(property)
local coords = nested_coords(property.enter)
or nested_coords(property.sellsign)
if coords then
return coords
end
local doors = decode_table(property.doors)
if doors then
for _, door in pairs(doors) do
local door_data = decode_table(door)
coords = door_data and nested_coords(door_data.door1) or nil
if coords then
return coords
end
end
end
coords = nested_coords(property.garage)
return coords or property_zone_coords(property)
end
local function property_identifier(property, fallback)
local value = type(property) == "table"
and (property.houseid or property.id or property.propertyid)
or nil
return normalize_property_id(value or fallback)
end
local function get_all_properties()
local success, properties = provider_call("GetAllProperties", function()
return exports[resource_name]:GetAllProperties()
end)
if not success then
return nil, "provider_error"
end
if type(properties) ~= "table" then
Bridge.Debug("error", "[sky_phone] %s:GetAllProperties returned invalid data.", resource_name)
return nil, "provider_error"
end
return properties
end
local function get_owned_properties(source)
local success, properties = provider_call("GetPlayerOwnedProperties", function()
return exports[resource_name]:GetPlayerOwnedProperties(source)
end)
if not success then
return nil, "provider_error"
end
if type(properties) ~= "table" then
Bridge.Debug("error", "[sky_phone] %s:GetPlayerOwnedProperties returned invalid data.", resource_name)
return nil, "provider_error"
end
return properties
end
local function get_property(property_id)
local success, property = provider_call("GetPropertyData", function()
return exports[resource_name]:GetPropertyData(property_id)
end)
if not success then
return nil, "provider_error"
end
if property == nil then
return nil, "property_not_found"
end
if type(property) ~= "table" then
Bridge.Debug("error", "[sky_phone] %s:GetPropertyData returned invalid data.", resource_name)
return nil, "provider_error"
end
return property
end
local function boolean_export(export_name, callback)
local success, value = provider_call(export_name, callback)
if not success then
return nil, "provider_error"
end
if type(value) ~= "boolean" then
Bridge.Debug("error", "[sky_phone] %s:%s returned a non-boolean value.", resource_name, export_name)
return nil, "provider_error"
end
return value
end
local function lock_status(property_id)
return boolean_export("GetPropertyLockStatus", function()
return exports[resource_name]:GetPropertyLockStatus(property_id)
end)
end
local function collect_catalog(catalog, properties)
local is_array = #properties > 0
for key, property in pairs(properties) do
if type(property) == "table" then
local property_id, id_key = property_identifier(property, is_array and nil or key)
if property_id then
catalog[id_key] = {
id = property_id,
property = property,
}
end
end
end
end
local function collect_owned(properties, catalog)
local owned = {}
local is_array = #properties > 0
for key, value in pairs(properties) do
local property = type(value) == "table" and value or nil
local fallback = property and (is_array and nil or key)
or (is_array and value or key)
local property_id, id_key = property_identifier(property, fallback)
if property_id then
owned[id_key] = property_id
if catalog and property then
catalog[id_key] = {
id = property_id,
property = property,
}
end
end
end
return owned
end
local function property_access(source, property, property_id, id_key, owned, identifier)
if owned[id_key] then
return "owner"
end
local owner = property.owner or property.Owner
if identifier and owner ~= nil and tostring(owner) == tostring(identifier) then
return "owner"
end
local has_keys, error_code = boolean_export("CheckPropertyKeys", function()
return exports[resource_name]:CheckPropertyKeys(source, property_id)
end)
if has_keys == nil then
return nil, error_code
end
if has_keys then
return "keyholder"
end
local has_permissions
has_permissions, error_code = boolean_export("HasPlayerAnyPropertyPermissions", function()
return exports[resource_name]:HasPlayerAnyPropertyPermissions(source, property_id)
end)
if has_permissions == nil then
return nil, error_code
end
return has_permissions and "keyholder" or nil
end
local function can_control_lock(source, property_id, access)
if access == "owner" then
return true
end
return boolean_export("GetPropertyPermission", function()
return exports[resource_name]:GetPropertyPermission(source, property_id, "unlocking")
end)
end
local function property_name(property, id_key)
local name = property.propertyname or property.name
if type(name) == "string" and name ~= "" then
return name
end
local address = property.adress or property.address
if type(address) == "string" and address ~= "" then
return address
end
return ("Property %s"):format(id_key)
end
local function normalized_property(property, property_id, id_key, access, coords, locked, can_lock)
return {
id = ("%s:%s"):format(provider_name, id_key),
providerId = id_key,
name = property_name(property, id_key),
access = access,
locked = locked,
entrance = coords,
capabilities = {
lock = can_lock == true,
keys = false,
waypoint = true,
cctv = false,
garageStatus = false,
},
cctv = { enabled = false },
garage = nil,
keys = nil,
}
end
local function actor_access(source, property, property_id, id_key)
local owned_properties, error_code = get_owned_properties(source)
if not owned_properties then
return nil, nil, error_code
end
local owned = collect_owned(owned_properties)
local identifier = Bridge.Framework.GetIdentifier(source)
local access
access, error_code = property_access(
source,
property,
property_id,
id_key,
owned,
identifier
)
if error_code then
return nil, nil, error_code
end
if not access then
return nil, nil, "property_access_denied"
end
local can_lock
can_lock, error_code = can_control_lock(source, property_id, access)
if can_lock == nil then
return nil, nil, error_code
end
return access, can_lock
end
local function resolve_property(source, data)
if type(data) ~= "table" or type(data.propertyId) ~= "string" then
return nil, nil, nil, nil, nil, "invalid_request"
end
local raw_id = data.propertyId:match("^rtx:(%d+)$")
local property_id, id_key = normalize_property_id(raw_id)
if not property_id then
return nil, nil, nil, nil, nil, "invalid_property"
end
local property, error_code = get_property(property_id)
if not property then
return nil, nil, nil, nil, nil, error_code
end
local access, can_lock
access, can_lock, error_code = actor_access(source, property, property_id, id_key)
if not access then
return nil, nil, nil, nil, nil, error_code
end
local coords = property_entrance(property)
if not coords then
return nil, nil, nil, nil, nil, "invalid_coordinates"
end
return property, property_id, id_key, access, can_lock, nil, coords
end
Bridge.Housing.RegisterProvider(provider_name, {
resource_name = resource_name,
is_available = function()
return GetResourceState(resource_name) == "started"
end,
get_overview = function(source)
local properties, error_code = get_all_properties()
if not properties then
return nil, error_code
end
local owned_properties
owned_properties, error_code = get_owned_properties(source)
if not owned_properties then
return nil, error_code
end
local catalog = {}
collect_catalog(catalog, properties)
local owned = collect_owned(owned_properties, catalog)
for id_key, property_id in pairs(owned) do
if not catalog[id_key] then
local property
property, error_code = get_property(property_id)
if not property then
return nil, error_code
end
catalog[id_key] = { id = property_id, property = property }
end
end
local identifier = Bridge.Framework.GetIdentifier(source)
local result = {}
for id_key, entry in pairs(catalog) do
local access
access, error_code = property_access(
source,
entry.property,
entry.id,
id_key,
owned,
identifier
)
if error_code then
return nil, error_code
end
if access then
local coords = property_entrance(entry.property)
if coords then
local locked
locked, error_code = lock_status(entry.id)
if locked == nil then
return nil, error_code
end
local can_lock
can_lock, error_code = can_control_lock(source, entry.id, access)
if can_lock == nil then
return nil, error_code
end
result[#result + 1] = normalized_property(
entry.property,
entry.id,
id_key,
access,
coords,
locked,
can_lock
)
else
Bridge.Debug(
"debug",
"[sky_phone] Ignored RTX property '%s' because no valid entrance was found.",
id_key
)
end
end
end
table.sort(result, function(left, right)
if left.access ~= right.access then
return left.access == "owner"
end
return string.lower(left.name) < string.lower(right.name)
end)
local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 50))
local limited = {}
for index = 1, math.min(#result, maximum) do
limited[index] = result[index]
end
return limited
end,
prepare = function(source, action, data)
local property, property_id, id_key, access, can_lock, error_code, coords =
resolve_property(source, data)
if not property then
return nil, error_code
end
if action == "set_waypoint" then
return { coords = coords }
end
if action == "toggle_lock" then
if not can_lock then
return nil, "property_access_denied"
end
return { providerId = id_key }
end
if action == "key_candidates" or action == "grant_key" or action == "revoke_key" then
return nil, "capability_unavailable"
end
if action == "open_cctv" then
return nil, "cctv_unavailable"
end
return nil, "invalid_action"
end,
})
local function execute_lock_action(source, data)
if not SkyPhone.AllowOperation(source, "housing_rtx_action", Config.Housing.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
if type(data) ~= "table" or data.action ~= "toggle_lock" then
return { success = false, error = "invalid_action" }
end
if GetResourceState(resource_name) ~= "started" then
return { success = false, error = "provider_unavailable" }
end
local property_id, id_key = normalize_property_id(data.providerId)
if not property_id then
return { success = false, error = "invalid_property" }
end
local property, error_code = get_property(property_id)
if not property then
return { success = false, error = error_code }
end
local access, can_lock
access, can_lock, error_code = actor_access(source, property, property_id, id_key)
if not access then
return { success = false, error = error_code }
end
if not can_lock then
return { success = false, error = "property_access_denied" }
end
local current
current, error_code = lock_status(property_id)
if current == nil then
return { success = false, error = error_code }
end
local requested = not current
local setter_success = provider_call("SetPropertyLockStatus", function()
return exports[resource_name]:SetPropertyLockStatus(property_id, requested)
end)
if not setter_success then
return { success = false, error = "provider_error" }
end
local verified
verified, error_code = lock_status(property_id)
if verified == nil then
return { success = false, error = error_code }
end
if verified ~= requested then
Bridge.Debug(
"error",
"[sky_phone] %s lock update verification failed for property %s.",
resource_name,
id_key
)
return { success = false, error = "action_failed" }
end
return { success = true }
end
Bridge.Callbacks.Register("sky_phone:housing:rtx:execute", function(source, data)
return execute_lock_action(source, data)
end)
@@ -0,0 +1,730 @@
local provider_name = "rx"
local resource_name = "RxHousing"
local function positive_integer(value)
local number = Bridge.Normalize.FiniteNumber(value)
if not number or number < 1 or number ~= math.floor(number) then
return nil
end
return number
end
local function table_like(value)
local value_type = type(value)
return value_type == "table" or value_type == "vector3" or value_type == "vector4"
end
local function field(value, key)
if not table_like(value) then
return nil
end
local success, result = pcall(function()
return value[key]
end)
return success and result or nil
end
local function decode_object(value)
if table_like(value) then
return value
end
if type(value) ~= "string" or value == "" then
return nil
end
local success, decoded = pcall(json.decode, value)
return success and table_like(decoded) and decoded or nil
end
local function non_empty_string(value)
if type(value) ~= "string" and type(value) ~= "number" then
return nil
end
local text = tostring(value):match("^%s*(.-)%s*$")
return text ~= "" and text or nil
end
local function normalized_coords(value)
value = decode_object(value)
return value and Bridge.Normalize.Coordinates(value) or nil
end
local function entrance_for(property)
if not table_like(property) then
return nil
end
for _, key in ipairs({ "entrance", "Entrance", "entry", "Entry" }) do
local coords = normalized_coords(field(property, key))
if coords then
return coords
end
end
for _, key in ipairs({ "coords", "coordinates", "location", "position" }) do
local container = decode_object(field(property, key))
if container then
for _, nested_key in ipairs({ "entrance", "Entrance", "entry", "Entry", "enter", "door" }) do
local coords = normalized_coords(field(container, nested_key))
if coords then
return coords
end
end
local coords = normalized_coords(container)
if coords then
return coords
end
end
end
return nil
end
local function owner_identifier(property)
local owner = field(property, "owner")
local direct = non_empty_string(owner)
if direct then
return direct
end
if not table_like(owner) then
return nil
end
for _, key in ipairs({ "identifier", "citizenid", "citizenId", "id" }) do
local value = non_empty_string(field(owner, key))
if value then
return value
end
end
return nil
end
local function same_identifier(left, right)
return left ~= nil and right ~= nil and tostring(left) == tostring(right)
end
local function property_name(property, property_id)
for _, key in ipairs({ "label", "name", "address", "adress" }) do
local value = non_empty_string(field(property, key))
if value then
return value
end
end
return ("Property %s"):format(property_id)
end
local function call_export(name, callback)
local success, result = pcall(callback)
if not success then
Bridge.Debug("error", "[sky_phone] RxHousing:%s failed: %s", name, tostring(result))
return nil, false
end
return result, true
end
local function get_all_properties()
local properties, success = call_export("GetAllProperties", function()
return exports[resource_name]:GetAllProperties()
end)
if not success or type(properties) ~= "table" then
if success then
Bridge.Debug("error", "[sky_phone] RxHousing:GetAllProperties returned invalid data.")
end
return nil, "provider_error"
end
return properties
end
local function property_id_for(key, property)
if table_like(property) then
for _, name in ipairs({ "id", "propertyId", "property_id" }) do
local property_id = positive_integer(field(property, name))
if property_id then
return property_id
end
end
end
return positive_integer(key)
end
local function property_entries(properties)
local result = {}
local seen = {}
for key, value in pairs(properties) do
local property = decode_object(value)
local property_id = property and property_id_for(key, property) or nil
if property_id and not seen[property_id] then
seen[property_id] = true
result[#result + 1] = { id = property_id, data = property }
end
end
table.sort(result, function(left, right)
return left.id < right.id
end)
return result
end
local function get_owned_property_ids(identifier)
local properties, success = call_export("GetOwnedProperties", function()
return exports[resource_name]:GetOwnedProperties(identifier)
end)
if not success or type(properties) ~= "table" then
if success then
Bridge.Debug("error", "[sky_phone] RxHousing:GetOwnedProperties returned invalid data.")
end
return nil, "provider_error"
end
local result = {}
local is_array = #properties > 0
local direct_id = property_id_for(nil, properties)
if direct_id then
result[direct_id] = true
return result
end
for key, value in pairs(properties) do
local property = decode_object(value)
local property_id = property and property_id_for(key, property)
or positive_integer(value)
if not property_id and (value == true or value == 1) then
property_id = positive_integer(key)
end
if not property_id and not is_array then
property_id = positive_integer(key)
end
if property_id then
result[property_id] = true
end
end
return result
end
local function get_property(property_id)
local property, success = call_export("GetProperty", function()
return exports[resource_name]:GetProperty(property_id)
end)
if not success then
return nil, "provider_error"
end
property = decode_object(property)
if not property then
return nil, "property_not_found"
end
return property
end
local function boolean_result(value)
return value == true or value == 1 or value == "true"
end
local function has_key(property_id, identifier)
local result, success = call_export("HasKey", function()
return exports[resource_name]:HasKey(property_id, identifier)
end)
if not success then
return nil, "provider_error"
end
return boolean_result(result)
end
local function online_source(identifier)
for _, player_source in ipairs(Bridge.Framework.GetPlayers() or {}) do
if same_identifier(Bridge.Framework.GetIdentifier(player_source), identifier) then
return player_source
end
end
return nil
end
local function player_name(player_source)
local first_name = Bridge.Framework.GetFirstname(player_source)
local last_name = Bridge.Framework.GetLastname(player_source)
local name = table.concat({ tostring(first_name or ""), tostring(last_name or "") }, " ")
:match("^%s*(.-)%s*$")
if name ~= "" then
return name
end
return GetPlayerName(player_source) or tostring(player_source)
end
local function keyholder_identifier(key, value)
if type(value) == "string" then
return non_empty_string(value)
end
if table_like(value) then
for _, name in ipairs({ "identifier", "citizenid", "citizenId", "playerIdentifier", "player_identifier" }) do
local identifier = non_empty_string(field(value, name))
if identifier then
return identifier
end
end
end
if type(key) == "string" and not tonumber(key) and (value == true or table_like(value)) then
return non_empty_string(key)
end
return nil
end
local function supplied_keyholder_name(value)
if not table_like(value) then
return nil
end
local direct = non_empty_string(field(value, "name") or field(value, "label"))
if direct then
return direct
end
local first_name = non_empty_string(field(value, "firstname") or field(value, "firstName")) or ""
local last_name = non_empty_string(field(value, "lastname") or field(value, "lastName")) or ""
local name = (first_name .. " " .. last_name):match("^%s*(.-)%s*$")
return name ~= "" and name or nil
end
local function get_keyholder_values(property_id)
local values, success = call_export("GetPropertyKeyholders", function()
return exports[resource_name]:GetPropertyKeyholders(property_id)
end)
if not success or type(values) ~= "table" then
if success then
Bridge.Debug("error", "[sky_phone] RxHousing:GetPropertyKeyholders returned invalid data.")
end
return nil, "provider_error"
end
return values
end
local function normalized_keyholders(property_id, excluded_identifier)
local values, error_code = get_keyholder_values(property_id)
if not values then
return nil, error_code
end
local result = {}
local seen = {}
for key, value in pairs(values) do
local identifier = keyholder_identifier(key, value)
if identifier and not same_identifier(identifier, excluded_identifier) and not seen[identifier] then
seen[identifier] = true
local player_source = online_source(identifier)
result[#result + 1] = {
identifier = identifier,
name = player_source and player_name(player_source)
or supplied_keyholder_name(value)
or identifier,
online = player_source ~= nil,
revocable = true,
}
end
end
table.sort(result, function(left, right)
return string.lower(left.name) < string.lower(right.name)
end)
return result
end
local function access_for(property_id, identifier, owned_properties)
if owned_properties[property_id] then
return "owner"
end
local allowed, error_code = has_key(property_id, identifier)
if allowed == nil then
return nil, error_code
end
return allowed and "keyholder" or nil
end
local function normalized_property(property_id, property, access, actor_identifier)
local entrance = entrance_for(property)
if not entrance then
return nil
end
local keys = nil
if access == "owner" then
local error_code
keys, error_code = normalized_keyholders(property_id, actor_identifier)
if not keys then
return nil, error_code
end
end
return {
id = ("rx:%s"):format(property_id),
providerId = tostring(property_id),
name = property_name(property, property_id),
access = access,
locked = false,
entrance = entrance,
capabilities = {
lock = false,
keys = access == "owner",
waypoint = true,
cctv = false,
garageStatus = false,
},
cctv = { enabled = false },
garage = nil,
keys = keys,
}
end
local function get_overview(source)
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return nil, "housing_unavailable"
end
local owned_properties, owned_error = get_owned_property_ids(identifier)
if not owned_properties then
return nil, owned_error
end
local properties, error_code = get_all_properties()
if not properties then
return nil, error_code
end
local result = {}
for _, entry in ipairs(property_entries(properties)) do
local access, access_error = access_for(entry.id, identifier, owned_properties)
if access_error then
return nil, access_error
end
if access then
local property, normalize_error = normalized_property(entry.id, entry.data, access, identifier)
if normalize_error then
return nil, normalize_error
end
if property then
result[#result + 1] = property
end
end
end
table.sort(result, function(left, right)
if left.access ~= right.access then
return left.access == "owner"
end
return string.lower(left.name) < string.lower(right.name)
end)
local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 0))
while #result > maximum do
result[#result] = nil
end
return result
end
local function parse_property_id(value)
if type(value) ~= "string" then
return nil
end
return positive_integer(value:match("^rx:(%d+)$"))
end
local function resolve_property(source, data)
local property_id = parse_property_id(data and data.propertyId)
if not property_id then
return nil, nil, nil, "invalid_property"
end
local property, error_code = get_property(property_id)
if not property then
return nil, nil, nil, error_code
end
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return nil, nil, nil, "housing_unavailable"
end
local owned_properties, owned_error = get_owned_property_ids(identifier)
if not owned_properties then
return nil, nil, nil, owned_error
end
local access, access_error = access_for(property_id, identifier, owned_properties)
if access_error then
return nil, nil, nil, access_error
end
if not access then
return nil, nil, nil, "property_access_denied"
end
if not entrance_for(property) then
return nil, nil, nil, "invalid_coordinates"
end
return property, property_id, access
end
local function player_source_for(key, value)
local direct = positive_integer(value)
if direct then
return direct
end
if table_like(value) then
for _, name in ipairs({ "source", "playerId", "player_id", "serverId", "server_id", "id" }) do
local player_source = positive_integer(field(value, name))
if player_source then
return player_source
end
end
end
if value == true or table_like(value) then
return positive_integer(key)
end
return nil
end
local function players_in_property(property_id)
local values, success = call_export("GetPlayersInProperty", function()
return exports[resource_name]:GetPlayersInProperty(property_id)
end)
if not success or type(values) ~= "table" then
if success then
Bridge.Debug("error", "[sky_phone] RxHousing:GetPlayersInProperty returned invalid data.")
end
return nil, "provider_error"
end
local result = {}
local seen = {}
for key, value in pairs(values) do
local player_source = player_source_for(key, value)
if player_source and not seen[player_source] and GetPlayerName(player_source) then
seen[player_source] = true
result[#result + 1] = player_source
end
end
return result
end
local function key_candidates(source, property, property_id)
local players, error_code = players_in_property(property_id)
if not players then
return nil, error_code
end
local owner = Bridge.Framework.GetIdentifier(source) or owner_identifier(property)
local result = {}
for _, target in ipairs(players) do
local identifier = Bridge.Framework.GetIdentifier(target)
if target ~= source and identifier and not same_identifier(identifier, owner) then
local allowed, access_error = has_key(property_id, identifier)
if allowed == nil then
return nil, access_error
end
if not allowed then
result[#result + 1] = {
id = target,
name = player_name(target),
}
end
end
end
table.sort(result, function(left, right)
return string.lower(left.name) < string.lower(right.name)
end)
return result
end
local function has_keyholder(property_id, identifier)
local keyholders, error_code = normalized_keyholders(property_id)
if not keyholders then
return nil, error_code
end
for _, keyholder in ipairs(keyholders) do
if same_identifier(keyholder.identifier, identifier) then
return true
end
end
return false
end
local function execute_key_action(source, data)
if not SkyPhone.AllowOperation(source, "housing_rx_action", Config.Housing.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
if GetResourceState(resource_name) ~= "started" then
return { success = false, error = "provider_unavailable" }
end
if type(data) ~= "table" or (data.action ~= "grant_key" and data.action ~= "revoke_key") then
return { success = false, error = "invalid_action" }
end
local property_id = positive_integer(data.providerId)
if not property_id then
return { success = false, error = "invalid_property" }
end
local property, error_code = get_property(property_id)
if not property then
return { success = false, error = error_code }
end
if not entrance_for(property) then
return { success = false, error = "invalid_coordinates" }
end
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return { success = false, error = "owner_required" }
end
local owned_properties, owned_error = get_owned_property_ids(identifier)
if not owned_properties then
return { success = false, error = owned_error }
end
if not owned_properties[property_id] then
return { success = false, error = "owner_required" }
end
if data.action == "grant_key" then
local target = positive_integer(data.target)
local target_identifier = target and Bridge.Framework.GetIdentifier(target) or nil
if not target or target == source or not target_identifier or not GetPlayerName(target) then
return { success = false, error = "invalid_target" }
end
if same_identifier(target_identifier, identifier) then
return { success = false, error = "invalid_target" }
end
local players, players_error = players_in_property(property_id)
if not players then
return { success = false, error = players_error }
end
local inside = false
for _, player_source in ipairs(players) do
if player_source == target then
inside = true
break
end
end
if not inside then
return { success = false, error = "target_not_in_property" }
end
local allowed, access_error = has_key(property_id, target_identifier)
if allowed == nil then
return { success = false, error = access_error }
end
if allowed then
return { success = false, error = "key_already_exists" }
end
local current_owned, current_error = get_owned_property_ids(identifier)
if not current_owned then
return { success = false, error = current_error }
end
if not current_owned[property_id] then
return { success = false, error = "owner_required" }
end
local _, success = call_export("AddKeyholder", function()
return exports[resource_name]:AddKeyholder(property_id, target_identifier)
end)
if not success then
return { success = false, error = "provider_error" }
end
local allowed_after, verify_error = has_key(property_id, target_identifier)
if allowed_after == nil then
return { success = false, error = verify_error }
end
return allowed_after
and { success = true }
or { success = false, error = "action_failed" }
end
local target_identifier = non_empty_string(data.identifier)
if not target_identifier or same_identifier(target_identifier, identifier) then
return { success = false, error = "invalid_target" }
end
local exists, key_error = has_keyholder(property_id, target_identifier)
if exists == nil then
return { success = false, error = key_error }
end
if not exists then
return { success = false, error = "key_not_found" }
end
local current_owned, current_error = get_owned_property_ids(identifier)
if not current_owned then
return { success = false, error = current_error }
end
if not current_owned[property_id] then
return { success = false, error = "owner_required" }
end
local _, success = call_export("RemoveKeyholder", function()
return exports[resource_name]:RemoveKeyholder(property_id, target_identifier)
end)
if not success then
return { success = false, error = "provider_error" }
end
local exists_after, verify_error = has_keyholder(property_id, target_identifier)
if exists_after == nil then
return { success = false, error = verify_error }
end
return not exists_after
and { success = true }
or { success = false, error = "action_failed" }
end
Bridge.Housing.RegisterProvider(provider_name, {
resource_name = resource_name,
is_available = function()
return GetResourceState(resource_name) == "started"
end,
get_overview = get_overview,
prepare = function(source, action, data)
if action == "toggle_lock" then
return nil, "capability_unavailable"
end
if action == "open_cctv" then
return nil, "cctv_unavailable"
end
local property, property_id, access, error_code = resolve_property(source, data)
if not property then
return nil, error_code
end
if action == "set_waypoint" then
return { coords = entrance_for(property) }
end
if access ~= "owner" then
return nil, "owner_required"
end
if action == "key_candidates" then
local candidates, candidates_error = key_candidates(source, property, property_id)
if not candidates then
return nil, candidates_error
end
return { candidates = candidates }
end
if action == "grant_key" then
local target = positive_integer(data and data.target)
if not target then
return nil, "invalid_target"
end
local candidates, candidates_error = key_candidates(source, property, property_id)
if not candidates then
return nil, candidates_error
end
for _, candidate in ipairs(candidates) do
if candidate.id == target then
return { providerId = tostring(property_id), target = target }
end
end
return nil, "target_not_in_property"
end
if action == "revoke_key" then
local target_identifier = non_empty_string(data and data.identifier)
local actor_identifier = Bridge.Framework.GetIdentifier(source)
if not target_identifier or same_identifier(target_identifier, actor_identifier) then
return nil, "invalid_target"
end
local exists, key_error = has_keyholder(property_id, target_identifier)
if exists == nil then
return nil, key_error
end
if not exists then
return nil, "key_not_found"
end
return { providerId = tostring(property_id), identifier = target_identifier }
end
return nil, "invalid_action"
end,
})
Bridge.Callbacks.Register("sky_phone:housing:rx:execute", function(source, data)
return execute_key_action(source, data)
end)
@@ -0,0 +1,393 @@
local provider_name = "sn"
local resource_name = "sn_properties"
local function same_identifier(left, right)
return left ~= nil and right ~= nil and tostring(left) == tostring(right)
end
local function normalized_property_id(value)
if type(value) == "table" then
value = value.id or value.propertyId or value.property_id
end
local property_id = tonumber(value)
if not property_id or property_id < 1 or property_id ~= math.floor(property_id) then
return nil
end
return property_id
end
local normalized_coords = Bridge.Normalize.Coordinates
local function get_all_properties()
local success, properties = pcall(function()
return exports[resource_name]:getAllProperties()
end)
if not success or type(properties) ~= "table" then
Bridge.Debug("error", "[sky_phone] sn_properties:getAllProperties failed: %s", tostring(properties))
return nil
end
return properties
end
local function collect_owned_property_ids(value, result)
local direct_id = normalized_property_id(value)
if direct_id then
result[direct_id] = true
return
end
if type(value) ~= "table" then
return
end
local array_length = #value
local is_dense_array = array_length > 0
if is_dense_array then
local entries = 0
for key in pairs(value) do
entries = entries + 1
if type(key) ~= "number" or key < 1 or key ~= math.floor(key) or key > array_length then
is_dense_array = false
end
end
is_dense_array = is_dense_array and entries == array_length
end
for key, entry in pairs(value) do
local entry_id = normalized_property_id(entry)
if entry_id then
result[entry_id] = true
else
local key_id = normalized_property_id(key)
local keyed_property = key_id and entry ~= nil and entry ~= false
and (entry == true or type(key) == "string" or not is_dense_array)
if keyed_property then
result[key_id] = true
end
end
end
end
local function get_owned_property_ids(source)
local success, properties = pcall(function()
return exports[resource_name]:getPlayerProperties(source)
end)
if not success then
Bridge.Debug("error", "[sky_phone] sn_properties:getPlayerProperties failed: %s", tostring(properties))
return nil
end
local result = {}
if properties == nil or properties == false then
return result
end
if type(properties) ~= "table" and type(properties) ~= "number" and type(properties) ~= "string" then
Bridge.Debug("error", "[sky_phone] sn_properties:getPlayerProperties returned an invalid value")
return nil
end
collect_owned_property_ids(properties, result)
return result
end
local function player_source(identifier)
for _, source in ipairs(Bridge.Framework.GetPlayers()) do
if same_identifier(Bridge.Framework.GetIdentifier(source), identifier) then
return source
end
end
return nil
end
local function player_name(source, fallback)
if source then
local name = Bridge.Framework.GetCharacterName(source) or GetPlayerName(source)
if type(name) == "string" then
name = name:match("^%s*(.-)%s*$")
if name ~= "" then
return name
end
end
end
return tostring(fallback)
end
local function property_keys(property)
return type(property.keys) == "table" and property.keys or {}
end
local function property_access(property, identifier, owned_property_ids)
local property_id = normalized_property_id(property)
if property_id and owned_property_ids[property_id] then
return "owner"
end
if property_keys(property)[tostring(identifier)] ~= nil then
return "keyholder"
end
return nil
end
local function normalized_keys(property, actor_identifier)
local keys = {}
for identifier in pairs(property_keys(property)) do
if (type(identifier) == "string" or type(identifier) == "number")
and not same_identifier(identifier, actor_identifier)
then
local normalized_identifier = tostring(identifier)
local source = player_source(normalized_identifier)
keys[#keys + 1] = {
identifier = normalized_identifier,
name = player_name(source, normalized_identifier),
online = source ~= nil,
revocable = true,
}
end
end
table.sort(keys, function(left, right)
return string.lower(left.name) < string.lower(right.name)
end)
return keys
end
local function normalized_property(property, access, actor_identifier)
local property_id = normalized_property_id(property)
local entrance = normalized_coords(property.coords)
if not property_id or property_id < 1 or property_id ~= math.floor(property_id) or not entrance then
return nil
end
local owner = access == "owner"
return {
id = ("sn:%s"):format(property_id),
providerId = tostring(property_id),
name = tostring(property.label or ("Property %s"):format(property_id)),
access = access,
locked = false,
entrance = entrance,
capabilities = {
lock = false,
keyGrant = false,
keys = owner,
waypoint = true,
cctv = false,
garageStatus = false,
},
cctv = { enabled = false },
garage = nil,
keys = owner and normalized_keys(property, actor_identifier) or nil,
}
end
local function find_property(properties, property_id)
if normalized_property_id(properties) == property_id then
return properties
end
for _, property in pairs(properties) do
if type(property) == "table" and tonumber(property.id) == property_id then
return property
end
end
return nil
end
local function parse_property_id(data)
if type(data) ~= "table" then
return nil, "invalid_request"
end
local property_id = type(data.propertyId) == "string"
and tonumber(data.propertyId:match("^sn:(%d+)$")) or nil
if not property_id then
property_id = tonumber(data.providerId)
end
if not property_id or property_id < 1 or property_id ~= math.floor(property_id) then
return nil, "invalid_property"
end
return property_id
end
local function resolve_property(source, data)
local property_id, error_code = parse_property_id(data)
if not property_id then
return nil, nil, nil, error_code
end
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return nil, nil, nil, "housing_unavailable"
end
identifier = tostring(identifier)
local owned_property_ids = get_owned_property_ids(source)
if not owned_property_ids then
return nil, nil, nil, "provider_error"
end
local properties = get_all_properties()
if not properties then
return nil, nil, nil, "provider_error"
end
local property = find_property(properties, property_id)
if not property or not normalized_coords(property.coords) then
return nil, nil, nil, "property_not_found"
end
local access = property_access(property, identifier, owned_property_ids)
if not access then
return nil, nil, nil, "property_access_denied"
end
return property, property_id, access, nil, identifier, owned_property_ids
end
local function execute_action(source, action, data)
if not SkyPhone.AllowOperation(source, "housing_sn_action", Config.Housing.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
if action == "grant_key" then
return { success = false, error = "capability_unavailable" }
end
if action ~= "revoke_key" then
return { success = false, error = "invalid_action" }
end
local property, property_id, access, error_code, actor_identifier, owned_property_ids = resolve_property(source, data)
if not property then
return { success = false, error = error_code }
end
if access ~= "owner" or not owned_property_ids[property_id] then
return { success = false, error = "owner_required" }
end
local identifier = data.identifier
if type(identifier) ~= "string" or identifier == "" or same_identifier(identifier, actor_identifier) then
return { success = false, error = "invalid_target" }
end
if property_keys(property)[identifier] == nil then
return { success = false, error = "key_not_found" }
end
local current_owned_property_ids = get_owned_property_ids(source)
if not current_owned_property_ids then
return { success = false, error = "provider_error" }
end
if not current_owned_property_ids[property_id] then
return { success = false, error = "owner_required" }
end
local success, result = pcall(function()
return exports[resource_name]:removeKeyholder(identifier, property_id)
end)
if not success then
Bridge.Debug("error", "[sky_phone] sn_properties:removeKeyholder failed: %s", tostring(result))
return { success = false, error = "provider_error" }
end
local updated_properties = get_all_properties()
if not updated_properties then
return { success = false, error = "provider_error" }
end
local updated_property = find_property(updated_properties, property_id)
if not updated_property then
return { success = false, error = "property_not_found" }
end
if property_keys(updated_property)[identifier] ~= nil then
return { success = false, error = result == false and "provider_rejected" or "action_failed" }
end
return { success = true }
end
Bridge.Housing.RegisterProvider(provider_name, {
resource_name = resource_name,
is_available = function()
return GetResourceState(resource_name) == "started"
end,
get_overview = function(source)
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return nil, "housing_unavailable"
end
identifier = tostring(identifier)
local owned_property_ids = get_owned_property_ids(source)
if not owned_property_ids then
return nil, "provider_error"
end
local properties = get_all_properties()
if not properties then
return nil, "provider_error"
end
local result = {}
for _, property in pairs(properties) do
if type(property) == "table" then
local access = property_access(property, identifier, owned_property_ids)
local normalized = access and normalized_property(property, access, identifier) or nil
if normalized then
result[#result + 1] = normalized
if #result >= Config.Housing.MaximumProperties then
break
end
end
end
end
table.sort(result, function(left, right)
if left.access ~= right.access then
return left.access == "owner"
end
return string.lower(left.name) < string.lower(right.name)
end)
return result
end,
prepare = function(source, action, data)
local property, property_id, access, error_code, actor_identifier = resolve_property(source, data)
if not property then
return nil, error_code
end
if action == "set_waypoint" then
return { coords = normalized_coords(property.coords) }
end
if action == "open_cctv" then
return nil, "cctv_unavailable"
end
if action == "toggle_lock" then
return nil, "capability_unavailable"
end
if action == "key_candidates" then
if access ~= "owner" then
return nil, "owner_required"
end
return { candidates = {} }
end
if action == "grant_key" then
if access ~= "owner" then
return nil, "owner_required"
end
return nil, "capability_unavailable"
end
if action == "revoke_key" then
if access ~= "owner" then
return nil, "owner_required"
end
local identifier = data.identifier
if type(identifier) ~= "string" or identifier == "" or same_identifier(identifier, actor_identifier) then
return nil, "invalid_target"
end
if property_keys(property)[identifier] == nil then
return nil, "key_not_found"
end
return { providerId = tostring(property_id), identifier = identifier }
end
return nil, "invalid_action"
end,
})
Bridge.Callbacks.Register("sky_phone:housing:sn:execute", function(source, data)
if type(data) ~= "table" or type(data.action) ~= "string" then
return { success = false, error = "invalid_request" }
end
return execute_action(source, data.action, data)
end)
@@ -0,0 +1,428 @@
local provider_name = "vms"
local resource_name = "vms_housing"
local function provider_call(export_name, callback)
local success, result = pcall(callback)
if not success then
Bridge.Debug(
"error",
"[sky_phone] %s:%s failed: %s",
resource_name,
export_name,
tostring(result)
)
return false, nil
end
return true, result
end
local function normalize_property_id(value)
if type(value) == "number" then
local number = Bridge.Normalize.FiniteNumber(value)
if not number or number < 1 or number ~= math.floor(number) then
return nil, nil
end
local integer = math.tointeger(number)
return integer, integer and tostring(integer) or nil
end
if type(value) ~= "string" then
return nil, nil
end
local normalized = value:match("^%s*(.-)%s*$")
if normalized == "" or #normalized > 128 or normalized:find("%c") then
return nil, nil
end
return normalized, normalized
end
local function decode_table(value)
local value_type = type(value)
if value_type == "table" or value_type == "vector3" or value_type == "vector4" then
return value
end
if value_type ~= "string" or value == "" then
return nil
end
local success, decoded = pcall(json.decode, value)
return success and type(decoded) == "table" and decoded or nil
end
local function normalized_coords(value)
local coords = Bridge.Normalize.Coordinates(value)
if not coords then
return nil
end
if math.abs(coords.x) < 0.001 and math.abs(coords.y) < 0.001 and math.abs(coords.z) < 0.001 then
return nil
end
return coords
end
local function property_identifier(property, fallback)
local value = type(property) == "table"
and (property.id or property.propertyId or property.property_id)
or nil
return normalize_property_id(value or fallback)
end
local function get_all_properties()
local success, properties = provider_call("GetAllProperties", function()
return exports[resource_name]:GetAllProperties()
end)
if not success then
return nil, "provider_error"
end
if type(properties) ~= "table" then
Bridge.Debug("error", "[sky_phone] %s:GetAllProperties returned invalid data.", resource_name)
return nil, "provider_error"
end
return properties
end
local function get_player_properties(source)
local success, properties = provider_call("GetPlayerProperties", function()
return exports[resource_name]:GetPlayerProperties(source)
end)
if not success then
return nil, "provider_error"
end
if type(properties) ~= "table" then
Bridge.Debug("error", "[sky_phone] %s:GetPlayerProperties returned invalid data.", resource_name)
return nil, "provider_error"
end
return properties
end
local function get_property(property_id)
local success, property = provider_call("GetProperty", function()
return exports[resource_name]:GetProperty(property_id)
end)
if not success then
return nil, "provider_error"
end
if property == nil then
return nil, "property_not_found"
end
if type(property) ~= "table" then
Bridge.Debug("error", "[sky_phone] %s:GetProperty returned invalid data.", resource_name)
return nil, "provider_error"
end
return property
end
local function boolean_export(export_name, callback)
local success, value = provider_call(export_name, callback)
if not success then
return nil, "provider_error"
end
if type(value) ~= "boolean" then
Bridge.Debug("error", "[sky_phone] %s:%s returned a non-boolean value.", resource_name, export_name)
return nil, "provider_error"
end
return value
end
local function collect_catalog(catalog, properties)
local direct_id, direct_key = property_identifier(properties)
if direct_id then
catalog[direct_key] = { id = direct_id, property = properties }
return
end
local is_array = #properties > 0
for key, property in pairs(properties) do
if type(property) == "table" then
local property_id, id_key = property_identifier(property, is_array and nil or key)
if property_id then
catalog[id_key] = {
id = property_id,
property = property,
}
end
end
end
end
local function collect_player_properties(properties, catalog)
local direct = {}
local direct_id, direct_key = property_identifier(properties)
if direct_id then
direct[direct_key] = direct_id
if catalog then
catalog[direct_key] = { id = direct_id, property = properties }
end
return direct
end
local is_array = #properties > 0
for key, value in pairs(properties) do
local property = type(value) == "table" and value or nil
local fallback = property and (is_array and nil or key)
or (is_array and value or key)
local property_id, id_key = property_identifier(property, fallback)
if property_id then
direct[id_key] = property_id
if catalog and property then
catalog[id_key] = {
id = property_id,
property = property,
}
end
end
end
return direct
end
local function identifiers_match(value, identifier)
return identifier ~= nil
and value ~= nil
and tostring(value) ~= ""
and tostring(value) == tostring(identifier)
end
local function property_access(source, property, property_id, id_key, direct, identifier)
if identifiers_match(property.owner, identifier) then
return "owner"
end
if identifiers_match(property.renter, identifier) or direct[id_key] then
return "keyholder"
end
if not identifier then
return nil
end
local has_keys, error_code = boolean_export("HasKeys", function()
return exports[resource_name]:HasKeys(source, identifier, property_id)
end)
if has_keys == nil then
return nil, error_code
end
if has_keys then
return "keyholder"
end
local has_permissions
has_permissions, error_code = boolean_export("HasAnyPermission", function()
return exports[resource_name]:HasAnyPermission(property_id, identifier)
end)
if has_permissions == nil then
return nil, error_code
end
return has_permissions and "keyholder" or nil
end
local function property_metadata(property)
return decode_table(property.metadata) or {}
end
local function property_entrance(property, building_cache)
local object_id = property.object_id
if object_id ~= nil and tostring(object_id) ~= "" then
local normalized_object_id, object_key = normalize_property_id(object_id)
if normalized_object_id then
local building = nil
local cache_hit = building_cache and building_cache[object_key] ~= nil
if cache_hit then
building = building_cache[object_key]
else
local object, error_code = get_property(normalized_object_id)
if not object and error_code ~= "property_not_found" then
return nil, error_code
end
building = object or false
if building_cache then
building_cache[object_key] = building
end
end
if type(building) == "table" and building.type == "building" then
local coords = normalized_coords(property_metadata(building).enter)
if coords then
return coords
end
end
end
end
local metadata = property_metadata(property)
return normalized_coords(metadata.enter) or normalized_coords(metadata.menu)
end
local function property_name(property, id_key)
if type(property.name) == "string" and property.name ~= "" then
return property.name
end
if type(property.address) == "string" and property.address ~= "" then
return property.address
end
return ("Property %s"):format(id_key)
end
local function normalized_property(property, id_key, access, coords)
local metadata = property_metadata(property)
return {
id = ("%s:%s"):format(provider_name, id_key),
providerId = id_key,
name = property_name(property, id_key),
access = access,
locked = metadata.locked == true,
entrance = coords,
capabilities = {
lock = false,
keys = false,
waypoint = true,
cctv = false,
garageStatus = false,
},
cctv = { enabled = false },
garage = nil,
keys = nil,
}
end
local function resolve_property(source, data)
if type(data) ~= "table" or type(data.propertyId) ~= "string" then
return nil, nil, nil, nil, "invalid_request"
end
local raw_id = data.propertyId:match("^vms:(.+)$")
local property_id, id_key = normalize_property_id(raw_id)
if not property_id then
return nil, nil, nil, nil, "invalid_property"
end
local property, error_code = get_property(property_id)
if not property then
return nil, nil, nil, nil, error_code
end
local player_properties
player_properties, error_code = get_player_properties(source)
if not player_properties then
return nil, nil, nil, nil, error_code
end
local direct = collect_player_properties(player_properties)
local identifier = Bridge.Framework.GetIdentifier(source)
local access
access, error_code = property_access(
source,
property,
property_id,
id_key,
direct,
identifier
)
if error_code then
return nil, nil, nil, nil, error_code
end
if not access then
return nil, nil, nil, nil, "property_access_denied"
end
local coords
coords, error_code = property_entrance(property)
if not coords then
return nil, nil, nil, nil, error_code or "invalid_coordinates"
end
return property, property_id, id_key, coords
end
Bridge.Housing.RegisterProvider(provider_name, {
resource_name = resource_name,
is_available = function()
return GetResourceState(resource_name) == "started"
end,
get_overview = function(source)
local properties, error_code = get_all_properties()
if not properties then
return nil, error_code
end
local player_properties
player_properties, error_code = get_player_properties(source)
if not player_properties then
return nil, error_code
end
local catalog = {}
collect_catalog(catalog, properties)
local direct = collect_player_properties(player_properties, catalog)
for id_key, property_id in pairs(direct) do
if not catalog[id_key] then
local property
property, error_code = get_property(property_id)
if not property then
return nil, error_code
end
catalog[id_key] = { id = property_id, property = property }
end
end
local identifier = Bridge.Framework.GetIdentifier(source)
local building_cache = {}
local result = {}
for id_key, entry in pairs(catalog) do
local access
access, error_code = property_access(
source,
entry.property,
entry.id,
id_key,
direct,
identifier
)
if error_code then
return nil, error_code
end
if access then
local coords
coords, error_code = property_entrance(entry.property, building_cache)
if error_code then
return nil, error_code
end
if coords then
result[#result + 1] = normalized_property(
entry.property,
id_key,
access,
coords
)
else
Bridge.Debug(
"debug",
"[sky_phone] Ignored VMS property '%s' because no valid entrance was found.",
id_key
)
end
end
end
table.sort(result, function(left, right)
if left.access ~= right.access then
return left.access == "owner"
end
return string.lower(left.name) < string.lower(right.name)
end)
local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 50))
local limited = {}
for index = 1, math.min(#result, maximum) do
limited[index] = result[index]
end
return limited
end,
prepare = function(source, action, data)
local property, property_id, id_key, coords, error_code =
resolve_property(source, data)
if not property then
return nil, error_code
end
if action == "set_waypoint" then
return { coords = coords }
end
if action == "key_candidates" or action == "grant_key" or action == "revoke_key"
or action == "toggle_lock"
then
return nil, "capability_unavailable"
end
if action == "open_cctv" then
return nil, "cctv_unavailable"
end
return nil, "invalid_action"
end,
})
+124 -5
View File
@@ -1,8 +1,10 @@
local call_provider_resources = {
yaca = "yaca-voice",
pma = "pma-voice",
saltychat = "saltychat",
}
local call_provider_aliases = {
["yaca-voice"] = "yaca",
["pma-voice"] = "pma",
salty = "saltychat",
}
@@ -17,6 +19,26 @@ local radio_provider_aliases = {
salty = "saltychat",
}
local function yaca_is_enabled()
if GetResourceState("yaca-voice") ~= "started" then
return false
end
local success, enabled = pcall(function()
return exports["yaca-voice"]:isEnabled()
end)
if not success then
Bridge.Debug(
"error",
"[sky_phone] Yaca could not report its availability: %s",
tostring(enabled),
{ always = true }
)
return false
end
return enabled == true
end
local function resolve_call_provider()
local configured = tostring(Config.Calls.VoiceProvider or "")
local selected = call_provider_aliases[configured] or configured
@@ -51,11 +73,17 @@ function Bridge.Calls.GetProvider()
end
function Bridge.Calls.IsAvailable()
return resolve_call_provider() ~= nil
local selected = resolve_call_provider()
return selected ~= nil and (selected ~= "yaca" or yaca_is_enabled())
end
function Bridge.Calls.SupportsSpeaker()
return Bridge.Speaker.IsEnabled() and resolve_call_provider() == "saltychat"
local selected = resolve_call_provider()
return Bridge.Speaker.IsEnabled() and (selected == "yaca" or selected == "saltychat")
end
function Bridge.Calls.SupportsMute()
return resolve_call_provider() == "yaca"
end
function Bridge.Calls.Start(identifier, player_handles)
@@ -63,6 +91,37 @@ function Bridge.Calls.Start(identifier, player_handles)
if selected == "pma" then
return true, selected
end
if selected == "yaca" then
if not yaca_is_enabled() then
return false, selected
end
local caller_source = tonumber(player_handles[1])
local target_source = tonumber(player_handles[2])
if not caller_source or not target_source then
Bridge.Debug(
"error",
"[sky_phone] Yaca refused call %s because a player source was invalid.",
tostring(identifier),
{ always = true }
)
return false, selected
end
local success, error_message = pcall(function()
exports["yaca-voice"]:callPlayer(caller_source, target_source, true)
end)
if not success then
Bridge.Debug(
"error",
"[sky_phone] Yaca could not start call %s: %s",
tostring(identifier),
tostring(error_message),
{ always = true }
)
return false, selected
end
return true, selected
end
if selected ~= "saltychat" then
return false, nil
end
@@ -85,6 +144,36 @@ end
function Bridge.Calls.Stop(identifier, player_handles, provider)
local selected = provider or resolve_call_provider()
if selected == "yaca" then
if GetResourceState("yaca-voice") ~= "started" then
return
end
local caller_source = tonumber(player_handles[1])
local target_source = tonumber(player_handles[2])
if not caller_source or not target_source then
Bridge.Debug(
"error",
"[sky_phone] Yaca could not stop call %s because a player source was invalid.",
tostring(identifier),
{ always = true }
)
return
end
local success, error_message = pcall(function()
exports["yaca-voice"]:callPlayer(caller_source, target_source, false)
end)
if not success then
Bridge.Debug(
"error",
"[sky_phone] Yaca could not stop call %s: %s",
tostring(identifier),
tostring(error_message),
{ always = true }
)
end
return
end
if selected ~= "saltychat" or GetResourceState("saltychat") ~= "started" then
return
end
@@ -108,17 +197,47 @@ function Bridge.Calls.SetSpeaker(player_source, enabled, provider)
return false
end
local selected = provider or resolve_call_provider()
if selected ~= "saltychat" or GetResourceState("saltychat") ~= "started" then
local resource_name = call_provider_resources[selected]
if (selected ~= "yaca" and selected ~= "saltychat")
or GetResourceState(resource_name) ~= "started"
then
return false
end
local success, error_message = pcall(function()
exports.saltychat:SetPhoneSpeaker(tonumber(player_source), enabled == true)
if selected == "yaca" then
exports["yaca-voice"]:enablePhoneSpeaker(tonumber(player_source), enabled == true)
else
exports.saltychat:SetPhoneSpeaker(tonumber(player_source), enabled == true)
end
end)
if not success then
Bridge.Debug(
"error",
"[sky_phone] SaltyChat could not update the phone speaker for source %s: %s",
"[sky_phone] %s could not update the phone speaker for source %s: %s",
selected == "yaca" and "Yaca" or "SaltyChat",
tostring(player_source),
tostring(error_message),
{ always = true }
)
return false
end
return true
end
function Bridge.Calls.SetMuted(player_source, enabled, provider)
local selected = provider or resolve_call_provider()
if selected ~= "yaca" or GetResourceState("yaca-voice") ~= "started" then
return false
end
local success, error_message = pcall(function()
exports["yaca-voice"]:muteOnPhone(tonumber(player_source), enabled == true)
end)
if not success then
Bridge.Debug(
"error",
"[sky_phone] Yaca could not update the phone mute state for source %s: %s",
tostring(player_source),
tostring(error_message),
{ always = true }
+38
View File
@@ -5,12 +5,50 @@ Bridge.Database = Bridge.Database or {}
Bridge.Framework = Bridge.Framework or {}
Bridge.Inventory = Bridge.Inventory or {}
Bridge.Radio = Bridge.Radio or {}
Bridge.Normalize = Bridge.Normalize or {}
Bridge.Speaker = Bridge.Speaker or {}
function Bridge.Normalize.FiniteNumber(value)
local number = tonumber(value)
if not number or number ~= number or number == math.huge or number == -math.huge then
return nil
end
return number
end
function Bridge.Normalize.Coordinates(value)
if value == nil then
return nil
end
local success, x, y, z = pcall(function()
return value.x or value[1], value.y or value[2], value.z or value[3]
end)
if not success then
return nil
end
x = Bridge.Normalize.FiniteNumber(x)
y = Bridge.Normalize.FiniteNumber(y)
z = Bridge.Normalize.FiniteNumber(z)
if not x or not y or not z then
return nil
end
return { x = x, y = y, z = z }
end
function Bridge.Speaker.IsEnabled()
return not Config.Speaker or Config.Speaker.Enabled ~= false
end
function Bridge.Calls.SupportsMute()
return false
end
function Bridge.Calls.SetMuted()
return false
end
function Bridge.Radio.SupportsSpeaker()
return false
end
+4 -4
View File
@@ -1,6 +1,6 @@
SkyPhoneFocus = {}
local blocked_phone_controls = { 19, 24, 140, 141, 142, 257, 263, 264 }
local blocked_phone_controls = { 24, 140, 141, 142, 257, 263, 264 }
local blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 }
local focused_control_groups = { 0, 1, 2 }
@@ -39,10 +39,10 @@ function SkyPhoneFocus.Resolve(state)
or state.payphone_focus
or state.sim_picker_open
or (state.camera_active and state.camera_nui_focused)
local cursor = focused and not (game_input and state.cursor_disabled)
local cursor = focused
return {
block_game = cursor,
block_look = game_input and not state.cursor_disabled,
block_game = cursor and (not game_input or state.text_input_focused),
block_look = game_input,
cursor = cursor,
focused = focused,
game_input = game_input,
+26 -2
View File
@@ -169,6 +169,17 @@ RegisterNUICallback("housing:overview", function(data, cb)
return
end
local result = Bridge.Callbacks.Trigger("sky_phone:housing:overview", {})
if type(result) == "table" and result.success and type(result.data) == "table" then
local properties, error_code = Bridge.Housing.EnrichOverview(
result.data.provider,
result.data.properties
)
if not properties then
cb({ success = false, error = error_code or "provider_error" })
return
end
result.data.properties = properties
end
cb(type(result) == "table" and result or { success = false, error = "request_failed" })
end)
@@ -211,11 +222,24 @@ RegisterNUICallback("housing:command", function(data, cb)
end
if data.action == "set_waypoint" then
local coords = prepared.data.coords
if type(coords) ~= "table" or not tonumber(coords.x) or not tonumber(coords.y) then
if coords == nil then
local success, error_code = Bridge.Housing.Execute(
prepared.data.provider,
data.action,
prepared.data
)
cb(success and { success = true }
or { success = false, error = error_code or "invalid_coordinates" })
return
end
local x = type(coords) == "table" and camera_number(coords.x) or nil
local y = type(coords) == "table" and camera_number(coords.y) or nil
if not x or not y then
cb({ success = false, error = "invalid_coordinates" })
return
end
SetNewWaypoint(tonumber(coords.x) + 0.0, tonumber(coords.y) + 0.0)
SetNewWaypoint(x + 0.0, y + 0.0)
cb({ success = true })
return
end
+16 -10
View File
@@ -15,7 +15,7 @@ local activity_suspended = false
local phone_block_game = false
local phone_block_look = false
local phone_game_input = false
local phone_cursor_disabled = false
local phone_text_input_focused = false
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsOpen", function()
return is_open
@@ -245,6 +245,7 @@ local server_callbacks = {
"calls:dial",
"calls:answer",
"calls:set-speaker",
"calls:set-muted",
"calls:decline",
"calls:hangup",
"calls:block",
@@ -326,20 +327,17 @@ local function update_nui_focus()
call_focus = call_focus,
camera_active = camera_active,
camera_nui_focused = camera_nui_focused,
cursor_disabled = phone_cursor_disabled,
is_open = is_open,
notification_focus = notification_focus,
payphone_focus = payphone_focus,
sim_picker_open = sim_picker_open,
text_input_focused = phone_text_input_focused,
})
SetNuiFocus(focus.focused, focus.cursor)
SetNuiFocusKeepInput(focus.keep_input)
phone_block_game = focus.block_game == true
phone_block_look = focus.block_look == true
phone_game_input = focus.game_input
if not phone_game_input and not phone_block_game then
phone_cursor_disabled = false
end
TriggerEvent("sky_phone:client:cameraFocusApplied", {
active = camera_active,
cursor = focus.cursor,
@@ -356,10 +354,6 @@ CreateThread(function()
else
SkyPhoneFocus.ApplyGameInputControls(phone_block_look)
end
if IsDisabledControlJustPressed(0, 19) then
phone_cursor_disabled = not phone_cursor_disabled
update_nui_focus()
end
Wait(0)
else
Wait(250)
@@ -417,6 +411,7 @@ local function close_phone(close_device_session)
open_requested = false
call_focus = false
activity_suspended = false
phone_text_input_focused = false
TriggerEvent("sky_phone:animation:phone", false)
is_open = false
if was_open then
@@ -499,6 +494,7 @@ RegisterNUICallback("ui:ready", function(data, cb)
-- Browser state is recreated on a CEF reload. A notification focus claim
-- cannot survive unless its notification is replayed as part of this handshake.
notification_focus = false
phone_text_input_focused = false
Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true })
SkyPhoneApps.SendCatalog()
if open_requested and device_payload then
@@ -554,6 +550,16 @@ RegisterNUICallback("ui:opened", function(data, cb)
cb({ success = true })
end)
RegisterNUICallback("ui:input-focus", function(data, cb)
if type(data) ~= "table" or type(data.active) ~= "boolean" then
cb({ success = false, error = "invalid_request" })
return
end
phone_text_input_focused = data.active and (is_open or open_requested)
update_nui_focus()
cb({ success = true })
end)
RegisterNUICallback("close", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
@@ -1085,7 +1091,7 @@ AddEventHandler("onResourceStop", function(resource_name)
active_call_payload = nil
activity_suspended = false
phone_game_input = false
phone_cursor_disabled = false
phone_text_input_focused = false
SetNuiFocusKeepInput(false)
SetNuiFocus(false, false)
+40 -2
View File
@@ -113,9 +113,12 @@ end
local function send_state(call, source, state, channel)
local outgoing = source == call.caller_source
local speaker_supported = Bridge.Speaker.IsEnabled() and (
call.voice_provider == "saltychat"
call.voice_provider == "yaca"
or call.voice_provider == "saltychat"
or (not call.voice_provider and Bridge.Calls.SupportsSpeaker())
)
local mute_supported = call.voice_provider == "yaca"
or (not call.voice_provider and Bridge.Calls.SupportsMute())
local payload = {
id = call.id,
state = state,
@@ -126,6 +129,8 @@ local function send_state(call, source, state, channel)
channel = channel,
speakerEnabled = call.speakers and call.speakers[source] == true or false,
speakerSupported = speaker_supported,
muted = call.muted and call.muted[source] == true or false,
muteSupported = mute_supported,
}
if call.payphone and outgoing then
payload.elapsedSeconds = call.payphone.elapsed_seconds or 0
@@ -252,6 +257,7 @@ local function finish_call(call, status)
end
Bridge.Calls.Stop(call.id, player_handles, call.voice_provider)
call.speakers = {}
call.muted = {}
call.voice_started = false
end
local ended_at = os.time()
@@ -1247,6 +1253,7 @@ Bridge.Callbacks.Register("sky_phone:calls:answer", function(source, data)
call.voice_provider = voice_provider
call.voice_started = true
call.speakers = {}
call.muted = {}
call.answered_at = os.time()
call.channel = next_voice_channel
next_voice_channel = next_voice_channel + 1
@@ -1281,7 +1288,7 @@ Bridge.Callbacks.Register("sky_phone:calls:set-speaker", function(source, data)
if not call or call.id ~= data.id or not call.answered_at or call.ended or not call.voice_started then
return { success = false, error = "call_not_found" }
end
if call.voice_provider ~= "saltychat" then
if call.voice_provider ~= "yaca" and call.voice_provider ~= "saltychat" then
return { success = false, error = "speaker_unsupported" }
end
if not Bridge.Speaker.IsEnabled() then
@@ -1302,6 +1309,37 @@ Bridge.Callbacks.Register("sky_phone:calls:set-speaker", function(source, data)
}
end)
Bridge.Callbacks.Register("sky_phone:calls:set-muted", function(source, data)
if type(data) ~= "table" or type(data.id) ~= "string" or type(data.enabled) ~= "boolean" then
return { success = false, error = "invalid_request" }
end
if not SkyPhone.AllowOperation(source, "call_mute", 30, 60) then
return { success = false, error = "rate_limited" }
end
local call_id = active_by_source[source]
local call = call_id and calls[call_id] or nil
if not call or call.id ~= data.id or not call.answered_at or call.ended or not call.voice_started then
return { success = false, error = "call_not_found" }
end
if call.voice_provider ~= "yaca" then
return { success = false, error = "mute_unsupported" }
end
if not Bridge.Calls.SetMuted(source, data.enabled, call.voice_provider) then
return { success = false, error = "voice_unavailable" }
end
call.muted[source] = data.enabled
send_state(call, source, "connected", call.channel)
return {
success = true,
data = {
muted = data.enabled,
muteSupported = true,
},
}
end)
Bridge.Callbacks.Register("sky_phone:calls:decline", function(source, data)
local call = type(data) == "table" and calls[data.id] or nil
if not call or call.callee_source ~= source or call.answered_at or call.rerouting then
+109 -11
View File
@@ -43,6 +43,13 @@ local function seed_hash(seed)
return value
end
local function darkchat_identifiers(account_id)
local account_key = tostring(account_id)
local dark_id = "DC" .. seed_hash("darkchat:id:" .. account_key):upper()
local invite_code = "I" .. seed_hash("darkchat:invite:" .. account_key):sub(1, 10):upper()
return dark_id, invite_code
end
local function database_uuid()
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
local id = rows[1] and rows[1].id
@@ -127,6 +134,71 @@ local function reserve_sim(owner_identifier, firstname, lastname)
return { id = sim_id, phone_number = number, sim_type = "registered" }
end
local function restore_sim_attachment(sim_id, current_imei, previous_imei)
local statements = {
{
query = "UPDATE `sky_phone_devices` SET `sim_id` = NULL WHERE `imei` = ? AND `sim_id` = ?",
params = { current_imei, sim_id },
},
}
if previous_imei then
statements[#statements + 1] = {
query = "UPDATE `sky_phone_devices` SET `sim_id` = ? WHERE `imei` = ? AND `sim_id` IS NULL",
params = { sim_id, previous_imei },
}
end
if not Bridge.Database.Transaction(statements) then
return false
end
local rows = Bridge.Database.Query(
"SELECT `imei` FROM `sky_phone_devices` WHERE `sim_id` = ? LIMIT 1",
{ sim_id }
)
if previous_imei then
return rows[1] and rows[1].imei == previous_imei
end
return rows[1] == nil
end
local function move_sim_to_device(sim_id, imei)
local rows = Bridge.Database.Query(
"SELECT `imei` FROM `sky_phone_devices` WHERE `sim_id` = ? LIMIT 1",
{ sim_id }
)
local previous_imei = rows[1] and rows[1].imei or nil
if previous_imei == imei then
return previous_imei
end
local moved = Bridge.Database.Transaction({
{
query = "UPDATE `sky_phone_devices` SET `sim_id` = NULL WHERE `sim_id` = ? AND `imei` <> ?",
params = { sim_id, imei },
},
{
query = "UPDATE `sky_phone_devices` SET `sim_id` = ? WHERE `imei` = ? AND `sim_id` IS NULL",
params = { sim_id, imei },
},
})
if not moved then
error("[sky_phone] Test data could not move the player's SIM to the selected phone.")
end
rows = Bridge.Database.Query(
"SELECT `sim_id` FROM `sky_phone_devices` WHERE `imei` = ? LIMIT 1",
{ imei }
)
if not rows[1] or rows[1].sim_id ~= sim_id then
if not restore_sim_attachment(sim_id, imei, previous_imei) then
error("[sky_phone] Test data could not verify the SIM move or restore its previous device.")
end
error("[sky_phone] Test data could not verify the SIM move.")
end
return previous_imei
end
local function ensure_bot(label, email_local, imei, firstname, lastname)
local account = ensure_account(email_local .. "@" .. Config.Mail.Domain)
local sim = reserve_sim("sky_phone:testbot:" .. label, firstname, lastname)
@@ -667,36 +739,57 @@ local function seed_social_apps(context)
INSERT IGNORE INTO `sky_phone_flare_profile_photos` (`profile_id`, `media_id`, `sort_order`)
VALUES (?, ?, 1), (?, ?, 1)
]], { flare_user, context.media.user_portrait, flare_bot, context.media.bot_two_portrait })
local match_id = stable_uuid(context.key .. ":flare:match")
local account_a = math.min(account_id, bot_two_id)
local account_b = math.max(account_id, bot_two_id)
local proposed_match_id = stable_uuid(
("sky_phone:testdata:flare:match:%s:%s"):format(account_a, account_b)
)
Bridge.Database.Query([[
INSERT INTO `sky_phone_flare_matches` (`id`, `account_a_id`, `account_b_id`)
VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `created_at` = `created_at`
]], { match_id, account_a, account_b })
local flare_message = stable_uuid(context.key .. ":flare:message")
]], { proposed_match_id, account_a, account_b })
local match_rows = Bridge.Database.Query([[
SELECT `id` FROM `sky_phone_flare_matches`
WHERE `account_a_id` = ? AND `account_b_id` = ? LIMIT 1
]], { account_a, account_b })
local match_id = match_rows[1] and match_rows[1].id or nil
if type(match_id) ~= "string" then
error("[sky_phone] Test data Flare match could not be loaded.")
end
local flare_body = "Hey! Bereit für einen vollständigen App-Test?"
local message_rows = Bridge.Database.Query([[
SELECT `id` FROM `sky_phone_flare_messages`
WHERE `match_id` = ? AND `sender_account_id` = ? AND `body` = ?
ORDER BY `created_at`, `id` LIMIT 1
]], { match_id, bot_two_id, flare_body })
local flare_message = message_rows[1] and message_rows[1].id
or stable_uuid("sky_phone:testdata:flare:message:" .. match_id)
Bridge.Database.Query([[
INSERT INTO `sky_phone_flare_messages` (`id`, `match_id`, `sender_account_id`, `body`, `read_at`)
VALUES (?, ?, ?, 'Hey! Bereit für einen vollständigen App-Test?', NULL)
VALUES (?, ?, ?, ?, NULL)
ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `read_at` = NULL
]], { flare_message, match_id, bot_two_id })
]], { flare_message, match_id, bot_two_id, flare_body })
end
local function seed_private_and_services(context)
local account_id = context.account.id
local bot_id = context.bot_one.account.id
local user_dark_id, user_invite_code = darkchat_identifiers(account_id)
local bot_dark_id, bot_invite_code = darkchat_identifiers(bot_id)
Bridge.Database.Query([[
INSERT INTO `sky_phone_darkchat_profiles`
(`account_id`, `dark_id`, `invite_code`, `alias`, `avatar_seed`, `notification_mode`, `activity_visible`)
VALUES (?, ?, ?, 'NightTester', 42, 'private', 1)
ON DUPLICATE KEY UPDATE `alias` = VALUES(`alias`), `notification_mode` = VALUES(`notification_mode`)
]], { account_id, ("DARK%010d"):format(account_id % 10000000000), ("INV%08d"):format(account_id % 100000000) })
ON DUPLICATE KEY UPDATE `dark_id` = VALUES(`dark_id`), `invite_code` = VALUES(`invite_code`),
`alias` = VALUES(`alias`), `notification_mode` = VALUES(`notification_mode`)
]], { account_id, user_dark_id, user_invite_code })
Bridge.Database.Query([[
INSERT INTO `sky_phone_darkchat_profiles`
(`account_id`, `dark_id`, `invite_code`, `alias`, `avatar_seed`, `notification_mode`, `activity_visible`)
VALUES (?, 'DARK0000000001', 'INV00000001', 'GhostAlex', 17, 'full', 1)
ON DUPLICATE KEY UPDATE `alias` = VALUES(`alias`)
]], { bot_id })
VALUES (?, ?, ?, 'GhostAlex', 17, 'full', 1)
ON DUPLICATE KEY UPDATE `dark_id` = VALUES(`dark_id`), `invite_code` = VALUES(`invite_code`),
`alias` = VALUES(`alias`)
]], { bot_id, bot_dark_id, bot_invite_code })
local dark_user = ensure_numeric_profile("sky_phone_darkchat_profiles", account_id)
local dark_bot = ensure_numeric_profile("sky_phone_darkchat_profiles", bot_id)
Bridge.Database.Query([[
@@ -897,7 +990,7 @@ local function seed_for_source(source)
sim = rows[1]
else
sim = reserve_sim(identifier, Bridge.Framework.GetFirstname(source), Bridge.Framework.GetLastname(source))
Bridge.Database.Query("UPDATE `sky_phone_devices` SET `sim_id` = ? WHERE `imei` = ?", { sim.id, imei })
local previous_imei = move_sim_to_device(sim.id, imei)
local metadata = phone_slot.metadata or {}
metadata.sim_id = sim.id
metadata.phone_number = sim.phone_number
@@ -908,6 +1001,11 @@ local function seed_for_source(source)
Config.Sim.NumberPrefix
)
if not Bridge.Inventory.SetSlotMetadata(source, phone_slot.slot, metadata) then
if not restore_sim_attachment(sim.id, imei, previous_imei) then
error(
"[sky_phone] Test data could not update the phone item's SIM metadata or restore its previous device."
)
end
error("[sky_phone] Test data could not update the phone item's SIM metadata.")
end
end
+91
View File
@@ -0,0 +1,91 @@
local RELEASE_API_URL = "https://api.github.com/repos/sky-systems/sky_phone/releases/latest"
local RELEASE_PAGE_URL = "https://github.com/sky-systems/sky_phone/releases/latest"
local function parse_version(version)
if type(version) ~= "string" then
return nil
end
local major, minor, patch = version:match("^v?(%d+)%.(%d+)%.(%d+)$")
if not major then
return nil
end
return tonumber(major), tonumber(minor), tonumber(patch)
end
local function compare_versions(installed_version, release_version)
local installed_major, installed_minor, installed_patch = parse_version(installed_version)
local release_major, release_minor, release_patch = parse_version(release_version)
if not installed_major or not release_major then
return nil
end
if installed_major ~= release_major then
return installed_major < release_major and -1 or 1
end
if installed_minor ~= release_minor then
return installed_minor < release_minor and -1 or 1
end
if installed_patch ~= release_patch then
return installed_patch < release_patch and -1 or 1
end
return 0
end
local function print_update_notice(installed_version, release_version)
local border = "======================================================================"
print(([[
^1%s^0
^1 SKY PHONE UPDATE AVAILABLE ^0
^1%s^0
^3 Installed version: ^1%s^0
^3 Latest release: ^2%s^0
^5 Download: %s^0
^1%s^0]]):format(border, border, installed_version, release_version, RELEASE_PAGE_URL, border))
end
local function check_for_update()
local resource_name = GetCurrentResourceName()
local installed_version = GetResourceMetadata(resource_name, "version", 0)
if not parse_version(installed_version) then
print(("^3[sky_phone] Update check skipped because fxmanifest.lua has an invalid version: %s.^0")
:format(tostring(installed_version)))
return
end
PerformHttpRequest(RELEASE_API_URL, function(status_code, response_body)
if status_code ~= 200 then
print(("^3[sky_phone] GitHub update check failed with HTTP %s.^0"):format(tostring(status_code)))
return
end
local decoded, release = pcall(json.decode, response_body)
local release_version = decoded and type(release) == "table" and release.tag_name or nil
local comparison = compare_versions(installed_version, release_version)
if not comparison then
print(("^3[sky_phone] GitHub update check returned an invalid release tag: %s.^0")
:format(tostring(release_version)))
return
end
if comparison < 0 then
print_update_notice(installed_version, release_version)
elseif comparison > 0 then
print(("^3[sky_phone] Installed version %s is newer than the latest GitHub release %s.^0")
:format(installed_version, release_version))
else
print(("^2[sky_phone] Version %s is up to date.^0"):format(installed_version))
end
end, "GET", "", {
["Accept"] = "application/vnd.github+json",
["User-Agent"] = "sky_phone-update-check",
["X-GitHub-Api-Version"] = "2022-11-28",
})
end
CreateThread(check_for_update)
+16 -14
View File
@@ -29,11 +29,11 @@ local function resolve(overrides)
call_focus = false,
camera_active = false,
camera_nui_focused = true,
cursor_disabled = false,
is_open = false,
notification_focus = false,
payphone_focus = false,
sim_picker_open = false,
text_input_focused = false,
}
for key, value in pairs(overrides or {}) do
state[key] = value
@@ -71,22 +71,23 @@ assert(
and movable_phone.focused
and movable_phone.keep_input
and movable_phone.game_input
and movable_phone.block_game,
"an open phone with the cursor active must block GTA hotkeys while keeping NUI input"
and not movable_phone.block_game
and movable_phone.block_look,
"an open phone must allow movement without hiding the NUI cursor"
)
local cursor_disabled_phone = resolve({
local typing_phone = resolve({
allow_movement = true,
cursor_disabled = true,
is_open = true,
text_input_focused = true,
})
assert(
not cursor_disabled_phone.cursor
and cursor_disabled_phone.focused
and cursor_disabled_phone.keep_input
and cursor_disabled_phone.game_input
and not cursor_disabled_phone.block_game,
"toggling Alt must release the NUI cursor while preserving phone and GTA input"
typing_phone.cursor
and typing_phone.focused
and typing_phone.keep_input
and typing_phone.game_input
and typing_phone.block_game,
"a focused phone text input must block GTA controls without hiding the NUI cursor"
)
SkyPhoneFocus.ApplyFocusedControls()
@@ -98,9 +99,10 @@ assert(firing_disabled, "focused phone cursor must block attacks while typing")
all_controls_disabled = {}
firing_disabled = false
SkyPhoneFocus.ApplyGameInputControls(true)
for _, control in ipairs({ 19, 24, 140, 141, 142, 257, 263, 264 }) do
for _, control in ipairs({ 24, 140, 141, 142, 257, 263, 264 }) do
assert(disabled_controls[control], ("phone control %d must remain disabled"):format(control))
end
assert(not disabled_controls[19], "Alt must remain available while no phone text input is focused")
for _, control in ipairs({ 1, 2, 3, 4, 5, 6 }) do
assert(disabled_controls[control], ("look control %d must be disabled while the phone cursor is active"):format(control))
end
@@ -111,8 +113,8 @@ assert(firing_disabled, "player attacks must remain disabled while the phone is
disabled_controls = {}
firing_disabled = false
SkyPhoneFocus.ApplyGameInputControls(false)
assert(not disabled_controls[1] and not disabled_controls[2], "Alt cursor toggle must restore camera look")
assert(firing_disabled, "player attacks must remain disabled after the cursor is toggled off")
assert(not disabled_controls[1] and not disabled_controls[2], "camera passthrough must preserve camera look")
assert(firing_disabled, "player attacks must remain disabled during camera passthrough")
local movable_notification = resolve({ allow_movement = true, notification_focus = true })
assert(
+69
View File
@@ -0,0 +1,69 @@
local source_path = "sky_phone/source/server/update_check.lua"
local original_print = print
local function run_check(installed_version, status_code, release_version)
local output = {}
local request
print = function(message)
output[#output + 1] = tostring(message)
end
GetCurrentResourceName = function()
return "sky_phone"
end
GetResourceMetadata = function(resource_name, key, index)
assert(resource_name == "sky_phone", "update check must read its own resource metadata")
assert(key == "version" and index == 0, "update check must read the fxmanifest version")
return installed_version
end
PerformHttpRequest = function(url, callback, method, body, headers)
request = {
url = url,
callback = callback,
method = method,
body = body,
headers = headers,
}
end
CreateThread = function(callback)
callback()
end
json = {
decode = function()
return { tag_name = release_version }
end,
}
dofile(source_path)
if request then
assert(request.url == "https://api.github.com/repos/sky-systems/sky_phone/releases/latest")
assert(request.method == "GET" and request.body == "", "update check must use a read-only GET request")
assert(request.headers["Accept"] == "application/vnd.github+json")
assert(request.headers["User-Agent"] == "sky_phone-update-check")
request.callback(status_code, "{}")
end
return table.concat(output, "\n"), request
end
local current_output = run_check("0.1.0", 200, "0.1.0")
assert(current_output:find("Version 0.1.0 is up to date", 1, true), "matching versions must report up to date")
local outdated_output = run_check("0.1.0", 200, "0.2.0")
assert(outdated_output:find("SKY PHONE UPDATE AVAILABLE", 1, true), "newer releases must show an update notice")
assert(outdated_output:find("Installed version: ^10.1.0", 1, true), "notice must show the manifest version")
assert(outdated_output:find("Latest release: ^20.2.0", 1, true), "notice must show the release tag")
local ahead_output = run_check("1.0.0", 200, "0.9.9")
assert(ahead_output:find("newer than the latest GitHub release", 1, true), "ahead versions must be distinguished")
local invalid_output, invalid_request = run_check("development", 200, "0.1.0")
assert(not invalid_request, "invalid manifest versions must not make an HTTP request")
assert(invalid_output:find("fxmanifest.lua has an invalid version", 1, true), "invalid manifests must be visible")
local failed_output = run_check("0.1.0", 429, "0.1.0")
assert(failed_output:find("GitHub update check failed with HTTP 429", 1, true), "HTTP failures must be visible")
print = original_print
io.write("Sky Phone update check tests passed\n")