mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 17:28:56 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b361922e2a | |||
| daa67cfb1e | |||
| 3eae2b8d30 | |||
| 734f4651cb | |||
| 98373e8bd8 | |||
| 64a7bc78c6 | |||
| 7985ca08d9 |
@@ -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
@@ -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
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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[]
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -464,6 +464,7 @@ onBeforeUnmount(() => {
|
||||
><strong>{{ phone.t('Apps.house.keys') }}</strong></span
|
||||
>
|
||||
<sky-button
|
||||
v-if="selectedProperty.capabilities.keyGrant !== false"
|
||||
rounded
|
||||
small
|
||||
inline
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user