From eefe3b53311fd7ea48ae6d97d251c0af3f9ee161 Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:11:49 +0200 Subject: [PATCH 1/5] ADD - add device passcode protection --- frontend/src/App.vue | 140 +++++++++++- frontend/src/components/PhonePasscode.vue | 215 ++++++++++++++++++ frontend/src/stores/phone-security.test.ts | 80 +++++++ frontend/src/stores/phone.ts | 106 ++++++++- frontend/src/types/device.ts | 7 + frontend/src/views/apps/SettingsApp.vue | 245 +++++++++++++++++++++ frontend/testserver/index.cjs | 47 ++++ sky_phone/config/config.lua | 7 + sky_phone/config/locales/en.lua | 16 +- sky_phone/source/client/main.lua | 4 + sky_phone/source/server/db_migrate.lua | 33 +++ sky_phone/source/server/phone.lua | 226 ++++++++++++++++++- sky_phone/sql/install.sql | 12 + 13 files changed, 1128 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/PhonePasscode.vue create mode 100644 frontend/src/stores/phone-security.test.ts diff --git a/frontend/src/App.vue b/frontend/src/App.vue index bbb2634..3af545f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -14,6 +14,7 @@ import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue' import PhoneControlCenter from '@/components/PhoneControlCenter.vue' import PhoneMediaCapture from '@/components/PhoneMediaCapture.vue' import PhoneLockScreen from '@/components/PhoneLockScreen.vue' +import PhonePasscode from '@/components/PhonePasscode.vue' import PhoneNotifications from '@/components/PhoneNotifications.vue' import NotificationPhonePreview from '@/components/NotificationPhonePreview.vue' import PhoneStatusBar from '@/components/PhoneStatusBar.vue' @@ -143,6 +144,13 @@ const appTransitionName = computed(() => ) const isLocked = ref(false) const isUnlocking = ref(false) +const passcodeBusy = ref(false) +const passcodeError = ref('') +const passcodeResetKey = ref(0) +const passcodeRetrySeconds = ref(0) +const passcodeVisible = ref(false) +const pendingUnlockRoute = ref(null) +const unlockedServicesLoaded = ref(false) const controlCenterOpened = ref(false) const simPicker = ref(null) const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)') @@ -168,6 +176,7 @@ const phoneFrameImage = computed( ) let clockTicker: ReturnType | undefined let unlockTimer: number | undefined +let passcodeLockTimer: number | undefined function getViewportScale(): number { const heightScale = window.innerHeight / REFERENCE_VIEWPORT_HEIGHT @@ -185,12 +194,17 @@ function hydratePhone(payload: PhoneOpenPayload): void { media.hydrate(payload.device?.data.media?.payload) appStore.hydrate(payload.device?.data.apps?.payload) widgets.hydrate(payload.device?.data.widgets?.payload) - void mail.bootstrap(payload.account?.email ?? '') - if (payload.account?.email) void marketplace.loadCounts() +} + +function loadUnlockedPhoneData(): void { + if (unlockedServicesLoaded.value) return + unlockedServicesLoaded.value = true + void mail.bootstrap(account.email) + if (account.email) void marketplace.loadCounts() else marketplace.setCounts({ active: 0, unread: 0 }) void calls.bootstrap() void messages.loadConversations() - if (payload.account?.email) void darkchat.bootstrap() + if (account.email) void darkchat.bootstrap() } async function hydrateDevelopmentPhone(): Promise { @@ -392,8 +406,11 @@ function onMessage(event: MessageEvent): void { ) { calls.applyCallState(event.data.data as PhoneCall) controlCenterOpened.value = false - isLocked.value = false - isUnlocking.value = false + if (!phone.security.enabled) { + isLocked.value = false + isUnlocking.value = false + loadUnlockedPhoneData() + } window.setTimeout(() => void router.push('/apps/phone'), 0) } else if (event.data?.type === 'sim:picker' && event.data.data) { simPicker.value = event.data.data as unknown as SimPickerPayload @@ -420,14 +437,78 @@ function updateViewportScale(): void { viewportScale.value = getViewportScale() } -function unlockPhone(): void { +function finishUnlock(): void { if (!isLocked.value) return isUnlocking.value = true isLocked.value = false + passcodeVisible.value = false + passcodeError.value = '' unlockTimer = window.setTimeout(() => { isUnlocking.value = false }, 720) + + if (pendingUnlockRoute.value) { + const routePath = pendingUnlockRoute.value + pendingUnlockRoute.value = null + window.setTimeout(() => void router.push(routePath), 0) + } + loadUnlockedPhoneData() +} + +function unlockPhone(): void { + if (!isLocked.value) return + if (phone.security.enabled) { + passcodeError.value = '' + passcodeVisible.value = true + return + } + finishUnlock() +} + +function cancelPasscode(): void { + if (passcodeBusy.value) return + passcodeVisible.value = false + passcodeError.value = '' + pendingUnlockRoute.value = null +} + +function startPasscodeLock(seconds: number): void { + if (passcodeLockTimer !== undefined) window.clearInterval(passcodeLockTimer) + passcodeRetrySeconds.value = Math.max(1, Math.ceil(seconds)) + passcodeLockTimer = window.setInterval(() => { + passcodeRetrySeconds.value = Math.max(0, passcodeRetrySeconds.value - 1) + if (passcodeRetrySeconds.value === 0 && passcodeLockTimer !== undefined) { + window.clearInterval(passcodeLockTimer) + passcodeLockTimer = undefined + passcodeError.value = '' + } + }, 1000) +} + +async function submitUnlockPasscode(passcode: string): Promise { + if (passcodeBusy.value || passcodeRetrySeconds.value > 0) return + passcodeBusy.value = true + const response = await phone.unlockWithPasscode(passcode) + passcodeBusy.value = false + if (response.success) { + finishUnlock() + return + } + + passcodeResetKey.value += 1 + if (response.error === 'passcode_locked') { + startPasscodeLock(response.data?.retryAfter ?? 30) + passcodeError.value = phone.t('LockScreen.passcode.locked', { + seconds: String(response.data?.retryAfter ?? 30), + }) + return + } + if (response.error === 'rate_limited') { + passcodeError.value = phone.t('LockScreen.passcode.rateLimited') + return + } + passcodeError.value = phone.t('LockScreen.passcode.incorrect') } function toggleControlCenter(): void { @@ -436,6 +517,11 @@ function toggleControlCenter(): void { } function unlockCamera(): void { + if (phone.security.enabled) { + pendingUnlockRoute.value = '/apps/camera' + unlockPhone() + return + } unlockPhone() window.setTimeout(() => void router.push('/apps/camera'), 0) } @@ -524,12 +610,33 @@ watch( controlCenterOpened.value = false isLocked.value = false isUnlocking.value = false + passcodeVisible.value = false + passcodeBusy.value = false + passcodeError.value = '' + pendingUnlockRoute.value = null + unlockedServicesLoaded.value = false + if (passcodeLockTimer !== undefined) { + window.clearInterval(passcodeLockTimer) + passcodeLockTimer = undefined + } return } isLocked.value = true + unlockedServicesLoaded.value = false controlCenterOpened.value = false weather.start() isUnlocking.value = false + passcodeVisible.value = false + passcodeBusy.value = false + passcodeError.value = '' + passcodeResetKey.value += 1 + passcodeRetrySeconds.value = Math.max( + 0, + (phone.security.lockedUntil ?? 0) - Math.floor(Date.now() / 1000), + ) + if (passcodeRetrySeconds.value > 0) { + startPasscodeLock(passcodeRetrySeconds.value) + } phone.setLaunchOrigin(null) void router.replace('/') }, @@ -546,6 +653,7 @@ onBeforeUnmount(() => { weather.stop() if (clockTicker) clearInterval(clockTicker) if (unlockTimer !== undefined) window.clearTimeout(unlockTimer) + if (passcodeLockTimer !== undefined) window.clearInterval(passcodeLockTimer) window.removeEventListener('message', onMessage) window.removeEventListener('keydown', onKeydown) window.removeEventListener('resize', updateViewportScale) @@ -637,6 +745,26 @@ onBeforeUnmount(() => { @unlock="unlockPhone" /> + + + +import { Delete } from 'lucide-vue-next' +import { computed, ref, watch } from 'vue' + +import { usePhoneStore } from '@/stores/phone' + +const props = withDefaults( + defineProps<{ + busy?: boolean + cancelable?: boolean + disabled?: boolean + error?: string + length: 4 | 6 + resetKey?: number + subtitle?: string + title: string + }>(), + { + busy: false, + cancelable: true, + disabled: false, + error: '', + resetKey: 0, + subtitle: '', + }, +) + +const emit = defineEmits<{ + cancel: [] + complete: [passcode: string] +}>() + +const phone = usePhoneStore() +const digits = ref('') +const keypad = [1, 2, 3, 4, 5, 6, 7, 8, 9] +const inputDisabled = computed(() => props.busy || props.disabled) + +function enterDigit(digit: number): void { + if (inputDisabled.value || digits.value.length >= props.length) return + digits.value += String(digit) + if (digits.value.length === props.length) emit('complete', digits.value) +} + +function removeDigit(): void { + if (inputDisabled.value) return + digits.value = digits.value.slice(0, -1) +} + +watch( + () => props.resetKey, + () => { + digits.value = '' + }, +) + + + + + diff --git a/frontend/src/stores/phone-security.test.ts b/frontend/src/stores/phone-security.test.ts new file mode 100644 index 0000000..740dca5 --- /dev/null +++ b/frontend/src/stores/phone-security.test.ts @@ -0,0 +1,80 @@ +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { usePhoneStore } from '@/stores/phone' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ + nuiCall: vi.fn(), +})) + +const mockNuiCall = vi.mocked(nuiCall) + +describe('phone passcode store', () => { + beforeEach(() => { + vi.stubGlobal('window', { + matchMedia: vi.fn(() => ({ matches: false })), + }) + setActivePinia(createPinia()) + mockNuiCall.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('stores the server security state after setting a six digit passcode', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { + security: { enabled: true, length: 6, lockedUntil: 0 }, + }, + success: true, + }) + + const phone = usePhoneStore() + const response = await phone.setPasscode('123456') + + expect(response.success).toBe(true) + expect(phone.security).toEqual({ + enabled: true, + length: 6, + lockedUntil: 0, + }) + expect(mockNuiCall).toHaveBeenCalledWith('security:set-passcode', { + passcode: '123456', + }) + }) + + it('keeps the configured state after a rejected unlock attempt', async () => { + mockNuiCall.mockResolvedValueOnce({ + error: 'invalid_passcode', + success: false, + }) + + const phone = usePhoneStore() + phone.security = { enabled: true, length: 4, lockedUntil: 0 } + await phone.unlockWithPasscode('9999') + + expect(phone.security).toEqual({ + enabled: true, + length: 4, + lockedUntil: 0, + }) + }) + + it('clears the security state after disabling the passcode', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { + security: { enabled: false, length: null, lockedUntil: 0 }, + }, + success: true, + }) + + const phone = usePhoneStore() + phone.security = { enabled: true, length: 4, lockedUntil: 0 } + await phone.disablePasscode('1234') + + expect(phone.security.enabled).toBe(false) + expect(phone.security.length).toBeNull() + }) +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 0238baa..65d609e 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -1,10 +1,15 @@ import { defineStore } from 'pinia' import type { AppLaunchOrigin, LaunchablePhoneAppId } from '@/types/apps' -import type { DeviceBootstrap, PhoneDevice } from '@/types/device' +import type { + DeviceBootstrap, + DeviceSecurity, + PhoneDevice, +} from '@/types/device' import { clampPage } from '@/utils/pages' import { cloneJsonData } from '@/utils/clone' import { nuiCall } from '@/utils/nui' +import type { NuiResponse } from '@/utils/nui' import { DEFAULT_PHONE_PREFERENCES, parsePhonePreferences, @@ -15,12 +20,19 @@ import { type LocaleTree = Record +export type PasscodeResponseData = { + attemptsRemaining?: number + retryAfter?: number + security?: DeviceSecurity +} + export type PhoneOpenPayload = { account?: DeviceBootstrap['account'] device?: PhoneDevice lang?: string locales?: LocaleTree notes?: DeviceBootstrap['notes'] + security?: DeviceSecurity token?: string } @@ -1322,6 +1334,7 @@ const defaultLocales: LocaleTree = { notifications: 'Notifications', sounds: 'Sounds & Haptics', general: 'General Settings', + security: 'Passcode & Security', appearance: 'Appearance', allowNotifications: 'Allow Notifications', notificationSounds: 'Sounds', @@ -1366,6 +1379,28 @@ const defaultLocales: LocaleTree = { 'This removes the account and all local data from this phone. Cloud data and the IMEI remain.', factoryResetProgress: 'Erasing iFruit Phone', factoryResetWarning: 'Do not turn off this phone. This takes 60 seconds.', + passcode: { + description: + 'A passcode protects the contents of this phone. It stays with the device when the SIM or iFruit account changes.', + status: 'Passcode', + codeLength: 'Code Length', + sixDigit: '6-Digit Code', + fourDigit: '4-Digit Code', + turnOn: 'Turn Passcode On', + turnOff: 'Turn Passcode Off', + change: 'Change Passcode', + enterNew: 'Enter New Passcode', + confirmNew: 'Verify New Passcode', + enterCurrent: 'Enter Current Passcode', + screenSubtitle: 'Use 4 or 6 numbers.', + incorrect: 'Incorrect passcode.', + mismatch: 'The passcodes did not match.', + locked: 'Too many incorrect attempts. Try again later.', + rateLimited: 'Too many attempts. Please wait.', + failed: 'The passcode could not be updated.', + saved: 'Passcode saved.', + disabled: 'Passcode turned off.', + }, accountErrors: { invalid_email: 'Choose a valid 3–32 character iFruit address.', invalid_password: 'Password must be 6–64 characters.', @@ -1460,6 +1495,16 @@ const defaultLocales: LocaleTree = { flashlight: 'Flashlight', camera: 'Camera', swipeUp: 'Swipe up to open', + passcode: { + enter: 'Enter Passcode', + unlockSubtitle: 'Enter the passcode for this phone.', + cancel: 'Cancel', + delete: 'Delete digit', + incorrect: 'Incorrect passcode', + locked: 'Too many attempts. Try again in {seconds} seconds.', + tryAgain: 'Try again in {seconds} seconds', + rateLimited: 'Too many attempts. Please wait.', + }, }, Home: { appLibrary: 'App Library', @@ -1575,6 +1620,11 @@ export const usePhoneStore = defineStore('phone', { launchOrigin: null as AppLaunchOrigin | null, locales: defaultLocales, preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES), + security: { + enabled: false, + length: null, + lockedUntil: 0, + } as DeviceSecurity, systemDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches, }), getters: { @@ -1593,6 +1643,11 @@ export const usePhoneStore = defineStore('phone', { this.lang = payload.lang ?? 'en' this.locales = payload.locales ?? defaultLocales if (payload.device) this.hydrateDevice(payload.device) + this.security = payload.security ?? { + enabled: false, + length: null, + lockedUntil: 0, + } this.isOpen = true }, hydrateDevice(device: PhoneDevice): void { @@ -1662,6 +1717,55 @@ export const usePhoneStore = defineStore('phone', { this.preferences.settings.wallpaper = wallpaper this.saveDeviceNamespace('settings', this.preferences) }, + async unlockWithPasscode( + passcode: string, + ): Promise> { + const response = await nuiCall( + 'security:unlock', + { passcode }, + ) + if (response.success && response.data?.security) { + this.security = response.data.security + } + return response + }, + async setPasscode( + passcode: string, + ): Promise> { + const response = await nuiCall( + 'security:set-passcode', + { passcode }, + ) + if (response.success && response.data?.security) { + this.security = response.data.security + } + return response + }, + async changePasscode( + currentPasscode: string, + newPasscode: string, + ): Promise> { + const response = await nuiCall( + 'security:change-passcode', + { currentPasscode, newPasscode }, + ) + if (response.success && response.data?.security) { + this.security = response.data.security + } + return response + }, + async disablePasscode( + passcode: string, + ): Promise> { + const response = await nuiCall( + 'security:disable-passcode', + { passcode }, + ) + if (response.success && response.data?.security) { + this.security = response.data.security + } + return response + }, t(path: string, replacements: Record = {}): string { const translated = getByPath(this.locales, path) const fallback = getByPath(defaultLocales, path) diff --git a/frontend/src/types/device.ts b/frontend/src/types/device.ts index 7f76d7c..06f7bd0 100644 --- a/frontend/src/types/device.ts +++ b/frontend/src/types/device.ts @@ -19,6 +19,12 @@ export type PhoneNotificationDevicePayload = { settings?: string | null } +export type DeviceSecurity = { + enabled: boolean + length: 4 | 6 | null + lockedUntil: number +} + export type AccountDevice = { created_at: string current: boolean @@ -37,5 +43,6 @@ export type DeviceBootstrap = { account: IfruitAccount | null device: PhoneDevice notes: Note[] + security: DeviceSecurity token: string } diff --git a/frontend/src/views/apps/SettingsApp.vue b/frontend/src/views/apps/SettingsApp.vue index 0f8ff3b..fd62c03 100644 --- a/frontend/src/views/apps/SettingsApp.vue +++ b/frontend/src/views/apps/SettingsApp.vue @@ -17,6 +17,8 @@ import { kPreloader, kRange, kSearchbar, + kSegmented, + kSegmentedButton, kToast, kToggle, } from 'konsta/vue' @@ -45,6 +47,7 @@ import { import { PHONE_FRAME_COLORS } from '@/config/appearance' import { isLaunchablePhoneApp, PHONE_APPS } from '@/config/apps' import { usePhoneStore } from '@/stores/phone' +import PhonePasscode from '@/components/PhonePasscode.vue' import { useAccountStore } from '@/stores/account' import type { LaunchablePhoneAppDefinition, @@ -74,6 +77,7 @@ import { type SettingsView = | 'root' | 'account' + | 'security' | 'notifications' | 'notification-detail' | 'sounds' @@ -82,6 +86,14 @@ type SettingsView = | 'wallpaper' type RootToggleKey = 'airplaneMode' | 'streamerMode' type SubmenuView = Exclude +type PasscodeFlow = + | 'set-new' + | 'set-confirm' + | 'change-current' + | 'change-new' + | 'change-confirm' + | 'disable' + | null const FACTORY_RESET_DURATION_MS = 60_000 const FACTORY_RESET_CIRCUMFERENCE = 2 * Math.PI * 48 @@ -110,6 +122,13 @@ const accountPassword = ref('') const accountConfirm = ref('') const accountSubmitting = ref(false) const accountToast = ref('') +const passcodeBusy = ref(false) +const passcodeCurrent = ref('') +const passcodeError = ref('') +const passcodeFirst = ref('') +const passcodeFlow = ref(null) +const passcodeLength = ref<4 | 6>(6) +const passcodeResetKey = ref(0) const removeDeviceImei = ref('') const removeDevicePassword = ref('') const removeDeviceOpened = ref(false) @@ -152,6 +171,12 @@ const serviceRows = [ }, ] const preferenceRows = [ + { + key: 'security', + view: 'security' as const, + icon: KeyRound, + iconColor: '#34c759', + }, { key: 'general', view: 'general' as const, @@ -204,6 +229,18 @@ const activeTitle = computed(() => { } return phone.t(`Apps.settings.${activeView.value}`) }) +const passcodeTitle = computed(() => { + if (passcodeFlow.value === 'set-confirm') { + return phone.t('Apps.settings.passcode.confirmNew') + } + if (passcodeFlow.value === 'change-current' || passcodeFlow.value === 'disable') { + return phone.t('Apps.settings.passcode.enterCurrent') + } + if (passcodeFlow.value === 'change-confirm') { + return phone.t('Apps.settings.passcode.confirmNew') + } + return phone.t('Apps.settings.passcode.enterNew') +}) function matchesSearch(key: string): boolean { return ( @@ -220,6 +257,9 @@ function updateSearch(event: Event): void { } function openView(view: SubmenuView): void { + if (view === 'security') { + passcodeLength.value = phone.security.length ?? 6 + } activeView.value = view scrollPageToTop() } @@ -248,6 +288,115 @@ function toggleRootSetting(key: RootToggleKey): void { phone.setPreference(key, !phone.preferences.settings[key]) } +function resetPasscodeInput(): void { + passcodeError.value = '' + passcodeResetKey.value += 1 +} + +function beginSetPasscode(): void { + passcodeLength.value = phone.security.length ?? passcodeLength.value + passcodeFirst.value = '' + passcodeCurrent.value = '' + passcodeFlow.value = 'set-new' + resetPasscodeInput() +} + +function beginChangePasscode(): void { + passcodeFirst.value = '' + passcodeCurrent.value = '' + passcodeFlow.value = 'change-current' + resetPasscodeInput() +} + +function beginDisablePasscode(): void { + passcodeLength.value = phone.security.length ?? 6 + passcodeCurrent.value = '' + passcodeFlow.value = 'disable' + resetPasscodeInput() +} + +function cancelPasscodeFlow(): void { + if (passcodeBusy.value) return + passcodeFlow.value = null + passcodeFirst.value = '' + passcodeCurrent.value = '' + resetPasscodeInput() +} + +function passcodeRequestError(error?: string): string { + if (error === 'invalid_passcode') { + return phone.t('Apps.settings.passcode.incorrect') + } + if (error === 'passcode_locked') { + return phone.t('Apps.settings.passcode.locked') + } + if (error === 'rate_limited') { + return phone.t('Apps.settings.passcode.rateLimited') + } + return phone.t('Apps.settings.passcode.failed') +} + +async function submitSettingsPasscode(passcode: string): Promise { + if (passcodeBusy.value || !passcodeFlow.value) return + + if (passcodeFlow.value === 'set-new') { + passcodeFirst.value = passcode + passcodeFlow.value = 'set-confirm' + resetPasscodeInput() + return + } + if (passcodeFlow.value === 'change-current') { + passcodeCurrent.value = passcode + passcodeFlow.value = 'change-new' + resetPasscodeInput() + return + } + if (passcodeFlow.value === 'change-new') { + passcodeFirst.value = passcode + passcodeFlow.value = 'change-confirm' + resetPasscodeInput() + return + } + if ( + (passcodeFlow.value === 'set-confirm' || + passcodeFlow.value === 'change-confirm') && + passcode !== passcodeFirst.value + ) { + passcodeError.value = phone.t('Apps.settings.passcode.mismatch') + passcodeResetKey.value += 1 + return + } + + passcodeBusy.value = true + const response = + passcodeFlow.value === 'set-confirm' + ? await phone.setPasscode(passcode) + : passcodeFlow.value === 'change-confirm' + ? await phone.changePasscode(passcodeCurrent.value, passcode) + : await phone.disablePasscode(passcode) + passcodeBusy.value = false + if (!response.success) { + passcodeError.value = passcodeRequestError(response.error) + if ( + passcodeFlow.value === 'change-confirm' && + response.error === 'invalid_passcode' + ) { + passcodeFlow.value = 'change-current' + passcodeCurrent.value = '' + passcodeFirst.value = '' + } + passcodeResetKey.value += 1 + return + } + + accountToast.value = phone.t( + passcodeFlow.value === 'disable' + ? 'Apps.settings.passcode.disabled' + : 'Apps.settings.passcode.saved', + ) + cancelPasscodeFlow() +} + function updateNumberPreference( key: | 'notificationDurationSeconds' @@ -551,6 +700,15 @@ onBeforeUnmount(() => { + @@ -715,6 +873,81 @@ onBeforeUnmount(() => { + + + +
{ }, }, notes: mockNotes, + security: mockSecurity, token: 'development', }, }) @@ -1667,12 +1670,56 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true, data: { revision } }) return } + if (endpoint === 'security:unlock') { + response.json( + !mockSecurity.enabled || request.body.passcode === mockPasscode + ? { success: true, data: { security: mockSecurity } } + : { success: false, error: 'invalid_passcode' }, + ) + return + } + if (endpoint === 'security:set-passcode') { + mockPasscode = String(request.body.passcode) + mockSecurity = { + enabled: true, + length: mockPasscode.length, + lockedUntil: 0, + } + response.json({ success: true, data: { security: mockSecurity } }) + return + } + if (endpoint === 'security:change-passcode') { + if (request.body.currentPasscode !== mockPasscode) { + response.json({ success: false, error: 'invalid_passcode' }) + return + } + mockPasscode = String(request.body.newPasscode) + mockSecurity = { + enabled: true, + length: mockPasscode.length, + lockedUntil: 0, + } + response.json({ success: true, data: { security: mockSecurity } }) + return + } + if (endpoint === 'security:disable-passcode') { + if (request.body.passcode !== mockPasscode) { + response.json({ success: false, error: 'invalid_passcode' }) + return + } + mockPasscode = '' + mockSecurity = { enabled: false, length: null, lockedUntil: 0 } + response.json({ success: true, data: { security: mockSecurity } }) + return + } if (endpoint === 'device:factory-reset') { authenticated = false linkedAccount = null mockNotes = [] mockMedia = [] calendarEvents = [] + mockPasscode = '' + mockSecurity = { enabled: false, length: null, lockedUntil: 0 } for (const key of Object.keys(deviceData)) delete deviceData[key] response.json({ success: true }) return diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index afc4287..fbfec09 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -19,6 +19,13 @@ Config.Phone = { DeviceName = "iFruit Phone", } +Config.Security = { + PasscodePepperConvar = "sky_phone_passcode_pepper", + MaximumAttempts = 5, + LockSeconds = 30, + AttemptsPerMinute = 12, +} + Config.Sim = { RegisteredItem = "sky_phone_sim_registered", AnonymousItem = "sky_phone_sim_anonymous", diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 29b140e..4956924 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -29,6 +29,11 @@ Locales["en"] = { Notifications = { now = "now" }, LockScreen = { label = "Lock Screen", flashlight = "Flashlight", camera = "Camera", swipeUp = "Swipe up to open", + passcode = { + enter = "Enter Passcode", unlockSubtitle = "Enter the passcode for this phone.", cancel = "Cancel", delete = "Delete digit", + incorrect = "Incorrect passcode", locked = "Too many attempts. Try again in {seconds} seconds.", + tryAgain = "Try again in {seconds} seconds", rateLimited = "Too many attempts. Please wait.", + }, }, Home = { appLibrary = "App Library", appLibrarySearch = "Search apps", allApps = "All Apps", apps = "Apps", @@ -545,7 +550,7 @@ Locales["en"] = { accountInformation = "Account Information", accountStatus = "Account Status", accountStatusValue = "Active", accountStorage = "Cloud Storage", accountStorageValue = "On Device", accountPurchases = "Media & Purchases", accountPurchasesValue = "Available", notifications = "Notifications", sounds = "Sounds & Haptics", - general = "General Settings", appearance = "Appearance", allowNotifications = "Allow Notifications", + general = "General Settings", security = "Passcode & Security", appearance = "Appearance", allowNotifications = "Allow Notifications", notificationSounds = "Sounds", notificationDuration = "Notification Duration", seconds = "{seconds} seconds", ringtoneVolume = "Ringtone Volume", notificationVolume = "Notification Volume", ringtone = "Ringtone", notificationSound = "Notification Sound", appearanceMode = "Appearance Mode", @@ -559,6 +564,15 @@ Locales["en"] = { removeDeviceBody = "Enter your iFruit password to remove this device from the account.", signOut = "Sign Out", factoryReset = "Erase All Content and Settings", factoryResetBody = "This removes the account and all local data from this phone. Cloud data and the IMEI remain.", factoryResetProgress = "Erasing iFruit Phone", factoryResetWarning = "Do not turn off this phone. This takes 60 seconds.", + passcode = { + description = "A passcode protects the contents of this phone. It stays with the device when the SIM or iFruit account changes.", + status = "Passcode", codeLength = "Code Length", sixDigit = "6-Digit Code", fourDigit = "4-Digit Code", + turnOn = "Turn Passcode On", turnOff = "Turn Passcode Off", change = "Change Passcode", + enterNew = "Enter New Passcode", confirmNew = "Verify New Passcode", enterCurrent = "Enter Current Passcode", + screenSubtitle = "Use 4 or 6 numbers.", incorrect = "Incorrect passcode.", mismatch = "The passcodes did not match.", + locked = "Too many incorrect attempts. Try again later.", rateLimited = "Too many attempts. Please wait.", + failed = "The passcode could not be updated.", saved = "Passcode saved.", disabled = "Passcode turned off.", + }, accountErrors = { invalid_email = "Choose a valid 3–32 character iFruit address.", invalid_password = "Password must be 6–64 characters.", invalid_credentials = "Email or password is incorrect.", email_taken = "That iFruit address is already registered.", diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 09ebfa3..b90dc12 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -8,6 +8,10 @@ local call_channel = 0 Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true }) local server_callbacks = { + "security:unlock", + "security:set-passcode", + "security:change-passcode", + "security:disable-passcode", "device:save", "device:factory-reset", "account:login", diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 4275e4d..59ecfc4 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -208,6 +208,39 @@ local schema = { }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, + { + name = "sky_phone_device_security", + columns = { + { + name = "device_imei", + type = "CHAR(15) NOT NULL", + characterSet = "ascii", + collation = "ascii_bin", + }, + { name = "passcode_hash", type = "BINARY(32) NOT NULL" }, + { + name = "passcode_salt", + type = "CHAR(32) NOT NULL", + characterSet = "ascii", + collation = "ascii_bin", + }, + { name = "passcode_length", type = "TINYINT UNSIGNED NOT NULL" }, + { name = "failed_attempts", type = "TINYINT UNSIGNED NOT NULL DEFAULT 0" }, + { name = "locked_until", type = "BIGINT UNSIGNED NOT NULL DEFAULT 0" }, + { + name = "updated_at", + type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP", + }, + }, + primaryKey = "device_imei", + foreignKeys = { + { + column = "device_imei", + references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE", + }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, { name = "sky_phone_notes", columns = { diff --git a/sky_phone/source/server/phone.lua b/sky_phone/source/server/phone.lua index 9306467..5339fbd 100644 --- a/sky_phone/source/server/phone.lua +++ b/sky_phone/source/server/phone.lua @@ -7,6 +7,15 @@ local sessions = {} local auth_attempts = {} local operation_attempts = {} local max_device_data_bytes = 100000 +local passcode_pepper = GetConvar(Config.Security.PasscodePepperConvar, "") +if passcode_pepper == "" then + Bridge.Debug( + "warn", + "[sky_phone] Passcode pepper convar '%s' is empty; configure it before production use.", + Config.Security.PasscodePepperConvar, + { always = true } + ) +end local allowed_device_namespaces = { settings = true, notifications = true, @@ -234,6 +243,98 @@ local function load_device_data(imei) return data end +local function load_device_security(imei) + local rows = Bridge.Database.Query([[ + SELECT `passcode_length`, `failed_attempts`, `locked_until` + FROM `sky_phone_device_security` + WHERE `device_imei` = ? + LIMIT 1 + ]], { imei }) + return rows[1] +end + +local function security_status(imei) + local security = load_device_security(imei) + return { + enabled = security ~= nil, + length = security and tonumber(security.passcode_length) or nil, + lockedUntil = security and tonumber(security.locked_until) or 0, + } +end + +local function valid_passcode(value) + return type(value) == "string" + and (#value == 4 or #value == 6) + and value:match("^%d+$") ~= nil +end + +local function passcode_matches(imei, passcode) + local rows = Bridge.Database.Query([[ + SELECT 1 AS `matches` + FROM `sky_phone_device_security` + WHERE `device_imei` = ? + AND `passcode_hash` = UNHEX(SHA2(CONCAT(?, `passcode_salt`, ?), 256)) + LIMIT 1 + ]], { imei, passcode_pepper, passcode }) + return rows[1] ~= nil +end + +local function verify_passcode(session, passcode) + if not valid_passcode(passcode) then + return false, { success = false, error = "invalid_passcode" } + end + + local security = load_device_security(session.imei) + if not security then + return false, { success = false, error = "passcode_not_set" } + end + + local now = os.time() + local locked_until = tonumber(security.locked_until) or 0 + if locked_until > now then + return false, { + success = false, + error = "passcode_locked", + data = { retryAfter = locked_until - now }, + } + end + + if passcode_matches(session.imei, passcode) then + Bridge.Database.Query([[ + UPDATE `sky_phone_device_security` + SET `failed_attempts` = 0, `locked_until` = 0 + WHERE `device_imei` = ? + ]], { session.imei }) + return true + end + + local failed_attempts = (tonumber(security.failed_attempts) or 0) + 1 + if failed_attempts >= Config.Security.MaximumAttempts then + local next_unlock = now + Config.Security.LockSeconds + Bridge.Database.Query([[ + UPDATE `sky_phone_device_security` + SET `failed_attempts` = 0, `locked_until` = ? + WHERE `device_imei` = ? + ]], { next_unlock, session.imei }) + return false, { + success = false, + error = "passcode_locked", + data = { retryAfter = Config.Security.LockSeconds }, + } + end + + Bridge.Database.Query([[ + UPDATE `sky_phone_device_security` + SET `failed_attempts` = ? + WHERE `device_imei` = ? + ]], { failed_attempts, session.imei }) + return false, { + success = false, + error = "invalid_passcode", + data = { attemptsRemaining = Config.Security.MaximumAttempts - failed_attempts }, + } +end + local function account_devices(account_id, current_imei) local rows = Bridge.Database.Query([[ SELECT `imei`, `device_name`, `created_at`, `updated_at` @@ -248,7 +349,7 @@ local function account_devices(account_id, current_imei) end local function bootstrap(source) - local session, error_response = SkyPhone.RequireSession(source) + local session, error_response = SkyPhone.RequireDeviceSession(source) if not session then return nil, error_response end @@ -260,6 +361,7 @@ local function bootstrap(source) return { token = session.token, + security = security_status(device.imei), device = { imei = device.imei, name = device.device_name, @@ -417,7 +519,7 @@ local function authenticate(source, data, registering) return link_account(source, accounts[1]) end -function SkyPhone.RequireSession(source) +function SkyPhone.RequireDeviceSession(source) local session = sessions[source] if not session then return nil, { success = false, error = "device_not_open" } @@ -433,6 +535,17 @@ function SkyPhone.RequireSession(source) return session end +function SkyPhone.RequireSession(source) + local session, error_response = SkyPhone.RequireDeviceSession(source) + if not session then + return nil, error_response + end + if not session.unlocked then + return nil, { success = false, error = "device_locked" } + end + return session +end + function SkyPhone.AllowOperation(source, operation, maximum, window_seconds) local now = os.time() operation_attempts[source] = operation_attempts[source] or {} @@ -565,10 +678,12 @@ local function open_phone(source, used_item) return false end + local security = load_device_security(imei) sessions[source] = { imei = imei, slot = slot.slot, token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())), + unlocked = security == nil, } local payload = bootstrap(source) Bridge.Debug( @@ -590,10 +705,12 @@ function SkyPhone.OpenDeviceForCall(source, imei) Bridge.Debug("warn", "[sky_phone] Could not open ringing device %s for source %s.", tostring(imei), tostring(source)) return false end + local security = load_device_security(imei) sessions[source] = { imei = imei, slot = matches[1].slot, token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())), + unlocked = security == nil, } TriggerClientEvent("sky_phone:device:open", source, bootstrap(source)) return true @@ -619,6 +736,106 @@ Bridge.Callbacks.Register("sky_phone:device:close", function(source) return { success = true } end) +Bridge.Callbacks.Register("sky_phone:security:unlock", function(source, data) + if not SkyPhone.AllowOperation(source, "security_unlock", Config.Security.AttemptsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local session, error_response = SkyPhone.RequireDeviceSession(source) + if not session then + return error_response + end + if session.unlocked then + return { success = true, data = { security = security_status(session.imei) } } + end + + local verified, verification_error = verify_passcode(session, data and data.passcode) + if not verified then + return verification_error + end + session.unlocked = true + return { success = true, data = { security = security_status(session.imei) } } +end) + +Bridge.Callbacks.Register("sky_phone:security:set-passcode", function(source, data) + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return error_response + end + local passcode = data and data.passcode + if not valid_passcode(passcode) then + return { success = false, error = "invalid_passcode" } + end + if load_device_security(session.imei) then + return { success = false, error = "passcode_already_set" } + end + + local salts = Bridge.Database.Query("SELECT REPLACE(UUID(), '-', '') AS `salt`", {}) + local salt = salts[1] and salts[1].salt + if type(salt) ~= "string" or #salt ~= 32 then + error("[sky_phone] Database did not generate a valid passcode salt.") + end + local result = Bridge.Database.Query([[ + INSERT INTO `sky_phone_device_security` + (`device_imei`, `passcode_hash`, `passcode_salt`, `passcode_length`) + VALUES (?, UNHEX(SHA2(CONCAT(?, ?, ?), 256)), ?, ?) + ]], { session.imei, passcode_pepper, salt, passcode, salt, #passcode }) + if affected_rows(result) ~= 1 then + return { success = false, error = "request_failed" } + end + return { success = true, data = { security = security_status(session.imei) } } +end) + +Bridge.Callbacks.Register("sky_phone:security:change-passcode", function(source, data) + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return error_response + end + local new_passcode = data and data.newPasscode + if not valid_passcode(new_passcode) then + return { success = false, error = "invalid_passcode" } + end + local verified, verification_error = verify_passcode(session, data and data.currentPasscode) + if not verified then + return verification_error + end + + local salts = Bridge.Database.Query("SELECT REPLACE(UUID(), '-', '') AS `salt`", {}) + local salt = salts[1] and salts[1].salt + if type(salt) ~= "string" or #salt ~= 32 then + error("[sky_phone] Database did not generate a valid passcode salt.") + end + local result = Bridge.Database.Query([[ + UPDATE `sky_phone_device_security` + SET `passcode_hash` = UNHEX(SHA2(CONCAT(?, ?, ?), 256)), + `passcode_salt` = ?, `passcode_length` = ?, `failed_attempts` = 0, `locked_until` = 0 + WHERE `device_imei` = ? + ]], { passcode_pepper, salt, new_passcode, salt, #new_passcode, session.imei }) + if affected_rows(result) ~= 1 then + return { success = false, error = "request_failed" } + end + return { success = true, data = { security = security_status(session.imei) } } +end) + +Bridge.Callbacks.Register("sky_phone:security:disable-passcode", function(source, data) + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return error_response + end + local verified, verification_error = verify_passcode(session, data and data.passcode) + if not verified then + return verification_error + end + local result = Bridge.Database.Query( + "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?", + { session.imei } + ) + if affected_rows(result) ~= 1 then + return { success = false, error = "request_failed" } + end + session.unlocked = true + return { success = true, data = { security = security_status(session.imei) } } +end) + Bridge.Callbacks.Register("sky_phone:device:development-open", function(source) if not Config.Phone.DevelopmentCommand then return { success = false, error = "disabled" } @@ -764,6 +981,10 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source) end local media_remote_ids = SkyPhoneMedia.GetDeviceRemoteIds(session.imei) if not Bridge.Database.Transaction({ + { + query = "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?", + params = { session.imei }, + }, { query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?", params = { session.imei }, @@ -792,6 +1013,7 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source) return { success = false, error = "request_failed" } end SkyPhoneMedia.CleanupRemoteFiles(media_remote_ids) + session.unlocked = true refresh_source(source) return { success = true } end) diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index fdb12b0..859a10d 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -92,6 +92,18 @@ CREATE TABLE IF NOT EXISTS `sky_phone_device_data` ( FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `sky_phone_device_security` ( + `device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `passcode_hash` BINARY(32) NOT NULL, + `passcode_salt` CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `passcode_length` TINYINT UNSIGNED NOT NULL, + `failed_attempts` TINYINT UNSIGNED NOT NULL DEFAULT 0, + `locked_until` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`device_imei`), + FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `sky_phone_notes` ( `id` VARCHAR(64) NOT NULL, `account_id` BIGINT UNSIGNED NULL, From 2ca96504d359d97478a7d4b952bad3aa8282a024 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Sun, 9 Aug 2026 01:50:11 +0200 Subject: [PATCH 2/5] ADD - implement FlipTok creator platform --- README.md | 20 + frontend/src/App.vue | 44 + frontend/src/assets/img/app-icons/fliptok.svg | 18 + frontend/src/components/PhoneMediaCapture.vue | 45 +- frontend/src/config/apps.test.ts | 9 +- frontend/src/config/apps.ts | 15 + frontend/src/stores/fliptok.test.ts | 133 + frontend/src/stores/fliptok.ts | 208 ++ frontend/src/stores/phone.ts | 120 + frontend/src/types/apps.ts | 1 + frontend/src/types/fliptok.ts | 97 + frontend/src/utils/preferences.ts | 1 + frontend/src/views/apps/CameraApp.vue | 155 +- frontend/src/views/apps/FlipTokApp.vue | 2473 +++++++++++++++++ frontend/src/views/apps/GalleryApp.vue | 30 +- frontend/testserver/index.cjs | 318 +++ sky_phone/config/config.lua | 12 + sky_phone/config/locales/en.lua | 35 +- sky_phone/fxmanifest.lua | 2 + .../source/bridge/server/frameworks/esx.lua | 14 + .../source/bridge/server/frameworks/qb.lua | 8 + .../source/bridge/server/frameworks/qbox.lua | 8 + sky_phone/source/client/main.lua | 35 + sky_phone/source/server/db_migrate.lua | 160 ++ sky_phone/source/server/fliptok.lua | 541 ++++ sky_phone/sql/install.sql | 75 + 26 files changed, 4523 insertions(+), 54 deletions(-) create mode 100644 frontend/src/assets/img/app-icons/fliptok.svg create mode 100644 frontend/src/stores/fliptok.test.ts create mode 100644 frontend/src/stores/fliptok.ts create mode 100644 frontend/src/types/fliptok.ts create mode 100644 frontend/src/views/apps/FlipTokApp.vue create mode 100644 sky_phone/source/server/fliptok.lua diff --git a/README.md b/README.md index 428972a..1c3d429 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,25 @@ # sky_phone +## FlipTok verification + +FlipTok verification is server-authoritative and limited to the framework groups configured in +`Config.FlipTok.AdminGroups`; no command ACE is required. Use +`/fliptokverify <@handle> [on|off]`. Without `on` or `off`, the current blue-check state is toggled. +The command name is configurable through `Config.FlipTok.VerifyCommand`. +Verification command access uses `Config.FlipTok.AdminGroups`. The report moderation overview is +server-authoritative and independently restricted through `Config.FlipTok.ReportAdminGroups`. + +## FlipTok music + +Licensed music can be exposed in the composer through `Config.FlipTok.MusicTracks`. Keep the IDs +stable because published videos store the selected ID; URLs must be directly playable by the NUI. + +```lua +MusicTracks = { + { Id = "night-drive", Title = "Night Drive", Artist = "Sky Radio", Url = "https://cdn.example.com/night-drive.ogg" }, +} +``` + Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. Each non-stackable `phone` item receives a unique 15-digit IMEI and owns its server-persisted device state. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly. An iFruit account is optional. Unlinked devices retain local settings, alarms, media, apps, notes, contacts, and recent calls. Linking from Mail or Settings moves local data into an empty cloud account; an existing cloud dataset wins over local contacts and recents. Signing out keeps an editable local snapshot without deleting cloud data. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3af545f..7f9714e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -30,6 +30,7 @@ import { useAccountStore } from '@/stores/account' import { useMailStore } from '@/stores/mail' import { useMessagesStore } from '@/stores/messages' import { useDarkChatStore } from '@/stores/darkchat' +import { useFlipTokStore } from '@/stores/fliptok' import { useMediaStore } from '@/stores/media' import { useMarketplaceStore } from '@/stores/marketplace' import { useAppStoreStore } from '@/stores/app-store' @@ -59,6 +60,8 @@ type AppMessage = { | MarketplaceEventData | MessagesEventData | DarkChatEventData + | FlipTokVerificationData + | FlipTokNotificationData | PhoneCall | PhoneNotificationInput | PhoneOpenPayload @@ -114,6 +117,20 @@ type CalendarReminderData = { text?: string title?: string } + +type FlipTokVerificationData = { + profileId: number + verified: boolean +} + +type FlipTokNotificationData = { + actor?: string + device?: PhoneNotificationDevicePayload + kind?: 'like' | 'comment' | 'follow' | 'verified' + text?: string + title?: string + videoId?: string +} const REFERENCE_VIEWPORT_WIDTH = 1920 const REFERENCE_VIEWPORT_HEIGHT = 1080 const PHONE_BASE_SCALE = 0.69 @@ -129,6 +146,7 @@ const banking = useBankingStore() const mail = useMailStore() const messages = useMessagesStore() const darkchat = useDarkChatStore() +const fliptok = useFlipTokStore() const media = useMediaStore() const marketplace = useMarketplaceStore() const appStore = useAppStoreStore() @@ -276,6 +294,32 @@ function onMessage(event: MessageEvent): void { } else if (event.data?.type === 'marketplace:changed' && event.data.data) { const data = event.data.data as MarketplaceEventData if (data.counts) marketplace.setCounts(data.counts) + } else if ( + event.data?.type === 'fliptok:verification-changed' && + event.data.data + ) { + const data = event.data.data as FlipTokVerificationData + fliptok.applyVerification(Number(data.profileId), data.verified === true) + } else if (event.data?.type === 'fliptok:new' && event.data.data) { + const data = event.data.data as FlipTokNotificationData + const notification: PhoneNotificationInput = { + appId: 'fliptok', + subtitle: data.actor, + text: data.text ?? phone.t('Apps.fliptok.notifications.default'), + title: data.title ?? phone.t('Apps.fliptok.name'), + } + if ( + data.device && + (!phone.isOpen || data.device.imei !== phone.device?.imei) + ) { + notification.device = { + imei: data.device.imei, + name: data.device.name, + preferences: parsePhonePreferences(data.device.settings ?? null), + } + } + notifications.show(notification) + if (phone.isOpen) void fliptok.loadActivities() } else if ( event.data?.type === 'marketplace:new-message' && event.data.data diff --git a/frontend/src/assets/img/app-icons/fliptok.svg b/frontend/src/assets/img/app-icons/fliptok.svg new file mode 100644 index 0000000..9cc1d98 --- /dev/null +++ b/frontend/src/assets/img/app-icons/fliptok.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/PhoneMediaCapture.vue b/frontend/src/components/PhoneMediaCapture.vue index cbf6c2c..cf6f48d 100644 --- a/frontend/src/components/PhoneMediaCapture.vue +++ b/frontend/src/components/PhoneMediaCapture.vue @@ -23,6 +23,7 @@ let renderFrameId: number | undefined let lastRenderAt = 0 let recorder: MediaRecorder | null = null let stream: MediaStream | null = null +let microphoneStream: MediaStream | null = null let chunks: RecordingChunk[] = [] let lastChunkAt = 0 let lastChunkTimecode: number | null = null @@ -95,7 +96,9 @@ function resetRecording(): void { function stopTracks(): void { stream?.getTracks().forEach((track) => track.stop()) + microphoneStream?.getTracks().forEach((track) => track.stop()) stream = null + microphoneStream = null } function cleanupRecording(): void { @@ -109,7 +112,7 @@ function cleanupRecording(): void { postRecordState(false) } -function startRecording(data: Record): void { +async function startRecording(data: Record): Promise { if (recorder) return if (typeof MediaRecorder === 'undefined') { window.postMessage( @@ -127,13 +130,45 @@ function startRecording(data: Record): void { } startRenderLoop() resetRecording() - stream = canvasRef.value?.captureStream(captureFps) ?? null - if (!stream) { + const videoStream = canvasRef.value?.captureStream(captureFps) ?? null + if (!videoStream) { cleanupRecording() return } + if (data.microphoneEnabled === true) { + try { + microphoneStream = await navigator.mediaDevices.getUserMedia({ + audio: { + autoGainControl: true, + echoCancellation: true, + noiseSuppression: true, + }, + }) + } catch { + videoStream.getTracks().forEach((track) => track.stop()) + cleanupRecording() + window.postMessage( + { + data: { error: 'microphone_unavailable', success: false }, + type: 'camera:recordError', + }, + '*', + ) + return + } + } + stream = new MediaStream([ + ...videoStream.getVideoTracks(), + ...(microphoneStream?.getAudioTracks() ?? []), + ]) + const mimeType = [ + 'video/webm;codecs=vp8,opus', + 'video/webm;codecs=vp8', + 'video/webm', + ].find((type) => MediaRecorder.isTypeSupported(type)) recorder = new MediaRecorder(stream, { - mimeType: 'video/webm', + ...(mimeType ? { mimeType } : {}), + audioBitsPerSecond: 128_000, videoBitsPerSecond: bitrateBps, }) recorder.ondataavailable = (event) => { @@ -311,7 +346,7 @@ function onMessage(event: MessageEvent): void { type?: string } if (message.type === 'camera:recordStart') { - startRecording(message.data ?? {}) + void startRecording(message.data ?? {}) } else if (message.type === 'camera:recordStop') { void stopRecording(message.data ?? {}) } else if (message.type === 'camera:recordCancel') { diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index 2ef0ae6..3d3910a 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -118,7 +118,14 @@ describe('app registry', () => { PHONE_APPS.filter((app) => app.category === 'social').map( (app) => app.id, ), - ).toEqual(['local-pages', 'phone', 'darkchat', 'banking', 'mail']) + ).toEqual([ + 'fliptok', + 'local-pages', + 'phone', + 'darkchat', + 'banking', + 'mail', + ]) expect( PHONE_APPS.filter((app) => app.dockOrder !== null) .sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0)) diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index e265d84..476001f 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -52,6 +52,7 @@ import bankingIcon from '@/assets/img/app-icons/banking.svg' import garageIcon from '@/assets/img/app-icons/garage.svg' import citymarktIcon from '@/assets/img/app-icons/citymarkt.webp' import localPagesIcon from '@/assets/img/app-icons/local-pages.webp' +import flipTokIcon from '@/assets/img/app-icons/fliptok.svg' import type { LaunchablePhoneAppDefinition, LaunchablePhoneAppId, @@ -59,6 +60,20 @@ import type { } from '@/types/apps' export const PHONE_APPS: PhoneAppDefinition[] = [ + { + category: 'social', + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/FlipTokApp.vue')), + ), + dockOrder: null, + gridOrder: 22, + icon: markRaw(Blocks), + iconClass: 'app-icon--fliptok', + iconImage: flipTokIcon, + id: 'fliptok', + labelKey: 'Apps.fliptok.name', + route: '/apps/fliptok', + }, { category: 'productivity', component: markRaw( diff --git a/frontend/src/stores/fliptok.test.ts b/frontend/src/stores/fliptok.test.ts new file mode 100644 index 0000000..a7f1dfd --- /dev/null +++ b/frontend/src/stores/fliptok.test.ts @@ -0,0 +1,133 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useFlipTokStore } from '@/stores/fliptok' +import type { + FlipTokActivity, + FlipTokComment, + FlipTokProfile, + FlipTokVideo, +} from '@/types/fliptok' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ + nuiCall: vi.fn(), +})) + +const profile: FlipTokProfile = { + account_type: 'person', + bio: '', + display_name: 'Nova', + followers: 1, + following: 2, + handle: 'nova', + id: 7, + is_following: false, + is_owner: true, + verified: false, + video_count: 1, +} + +const video: FlipTokVideo = { + caption: 'Los Santos', + comment_count: 1, + comments_enabled: true, + created_at: 1, + display_name: 'Nova', + handle: 'nova', + id: 'video-1', + is_following: false, + is_liked: false, + is_owner: true, + is_saved: false, + like_count: 2, + location: '', + cover_time_ms: 0, + music_artist: '', + music_title: '', + music_track: '', + music_url: '', + music_volume: 0, + original_volume: 100, + profile_id: 7, + share_count: 0, + trim_end_ms: null, + trim_start_ms: 0, + url: 'https://example.com/video.webm', + verified: false, + view_count: 3, +} + +const comment: FlipTokComment = { + body: 'Nice', + created_at: 1, + display_name: 'Nova', + handle: 'nova', + id: 'comment-1', + profile_id: 7, + verified: false, +} + +const activity: FlipTokActivity = { + created_at: 1, + display_name: 'Nova', + handle: 'nova', + id: 'activity-1', + kind: 'follow', + profile_id: 7, + read_at: null, + verified: false, + video_id: null, +} + +describe('FlipTok verification updates', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.mocked(nuiCall).mockReset() + }) + + it('updates the badge everywhere the profile is already visible', () => { + const store = useFlipTokStore() + store.profile = { ...profile } + store.feed = [{ ...video }] + store.searchResults = [{ ...video }] + store.comments = [{ ...comment }] + store.activities = [{ ...activity }] + + store.applyVerification(7, true) + + expect(store.profile.verified).toBe(true) + expect(store.feed[0].verified).toBe(true) + expect(store.searchResults[0].verified).toBe(true) + expect(store.comments[0].verified).toBe(true) + expect(store.activities[0].verified).toBe(true) + }) + + it('does not alter another profile', () => { + const store = useFlipTokStore() + store.feed = [{ ...video }] + + store.applyVerification(99, true) + + expect(store.feed[0].verified).toBe(false) + }) + + it('removes a blocked creator from every visible surface', async () => { + vi.mocked(nuiCall).mockResolvedValue({ success: true }) + const store = useFlipTokStore() + store.feed = [{ ...video }] + store.searchResults = [{ ...video }] + store.profileVideos = [{ ...video }] + store.comments = [{ ...comment }] + store.activities = [{ ...activity }] + store.viewedProfile = { ...profile } + + expect(await store.blockProfile(7)).toBe(true) + expect(store.feed).toEqual([]) + expect(store.searchResults).toEqual([]) + expect(store.profileVideos).toEqual([]) + expect(store.comments).toEqual([]) + expect(store.activities).toEqual([]) + expect(store.viewedProfile).toBeNull() + }) +}) diff --git a/frontend/src/stores/fliptok.ts b/frontend/src/stores/fliptok.ts new file mode 100644 index 0000000..022bc6f --- /dev/null +++ b/frontend/src/stores/fliptok.ts @@ -0,0 +1,208 @@ +import { defineStore } from 'pinia' + +import type { + FlipTokActivity, + FlipTokComment, + FlipTokMusicTrack, + FlipTokPage, + FlipTokProfile, + FlipTokProfilePage, + FlipTokReport, + FlipTokVideo, +} from '@/types/fliptok' +import { nuiCall, type NuiResponse } from '@/utils/nui' + +export const useFlipTokStore = defineStore('fliptok', { + state: () => ({ + activities: [] as FlipTokActivity[], + comments: [] as FlipTokComment[], + feed: [] as FlipTokVideo[], + isAdmin: false, + loading: false, + musicTracks: [] as FlipTokMusicTrack[], + mode: 'for-you' as 'for-you' | 'following', + profile: null as FlipTokProfile | null, + profileVideos: [] as FlipTokVideo[], + reports: [] as FlipTokReport[], + searchResults: [] as FlipTokVideo[], + viewedProfile: null as FlipTokProfile | null, + }), + actions: { + applyVerification(profileId: number, verified: boolean): void { + if (this.profile?.id === profileId) this.profile.verified = verified + if (this.viewedProfile?.id === profileId) + this.viewedProfile.verified = verified + this.feed + .filter((video) => video.profile_id === profileId) + .forEach((video) => { + video.verified = verified + }) + this.searchResults + .filter((video) => video.profile_id === profileId) + .forEach((video) => { + video.verified = verified + }) + this.profileVideos + .filter((video) => video.profile_id === profileId) + .forEach((video) => { + video.verified = verified + }) + this.comments + .filter((comment) => comment.profile_id === profileId) + .forEach((comment) => { + comment.verified = verified + }) + this.activities + .filter((activity) => activity.profile_id === profileId) + .forEach((activity) => { + activity.verified = verified + }) + }, + async bootstrap(): Promise { + this.loading = true + const response = await nuiCall<{ + feed: FlipTokPage + isAdmin: boolean + musicTracks: FlipTokMusicTrack[] + profile: FlipTokProfile + }>('fliptok:bootstrap') + this.loading = false + if (!response.success || !response.data) return false + this.profile = response.data.profile + this.feed = response.data.feed.items + this.isAdmin = response.data.isAdmin === true + this.musicTracks = response.data.musicTracks ?? [] + return true + }, + async loadFeed(mode?: 'for-you' | 'following'): Promise { + mode ??= this.mode + this.mode = mode + this.loading = true + const response = await nuiCall('fliptok:feed', { + mode, + offset: 0, + }) + this.loading = false + if (!response.success || !response.data) return false + this.feed = response.data.items + return true + }, + async discover(search: string): Promise { + const response = await nuiCall('fliptok:discover', { + search, + }) + this.searchResults = + response.success && response.data ? response.data : [] + return this.searchResults + }, + async react(video: FlipTokVideo, kind: 'like' | 'save'): Promise { + const key = kind === 'like' ? 'is_liked' : 'is_saved' + const next = !video[key] + video[key] = next + if (kind === 'like') video.like_count += next ? 1 : -1 + const response = await nuiCall('fliptok:react', { + active: next, + id: video.id, + kind, + }) + if (!response.success) { + video[key] = !next + if (kind === 'like') video.like_count += next ? -1 : 1 + } + }, + async follow(video: FlipTokVideo): Promise { + const next = !video.is_following + const response = await nuiCall('fliptok:follow', { + active: next, + profileId: video.profile_id, + }) + if (response.success) + this.feed + .filter((item) => item.profile_id === video.profile_id) + .forEach((item) => { + item.is_following = next + }) + }, + async followProfile(profile: FlipTokProfile): Promise { + const next = !profile.is_following + const response = await nuiCall('fliptok:follow', { + active: next, + profileId: profile.id, + }) + if (!response.success) return + profile.is_following = next + profile.followers += next ? 1 : -1 + this.feed + .filter((item) => item.profile_id === profile.id) + .forEach((item) => { + item.is_following = next + }) + }, + async loadProfile(query: { + handle?: string + profileId?: number + }): Promise { + const response = await nuiCall( + 'fliptok:profile', + query, + ) + if (!response.success || !response.data) return false + this.viewedProfile = response.data.profile + this.profileVideos = response.data.videos + return true + }, + showOwnProfile(): void { + this.viewedProfile = null + this.profileVideos = this.feed.filter((item) => item.is_owner) + }, + async blockProfile(profileId: number): Promise { + const response = await nuiCall('fliptok:block', { profileId }) + if (!response.success) return false + this.feed = this.feed.filter((video) => video.profile_id !== profileId) + this.searchResults = this.searchResults.filter( + (video) => video.profile_id !== profileId, + ) + this.comments = this.comments.filter( + (comment) => comment.profile_id !== profileId, + ) + this.activities = this.activities.filter( + (activity) => activity.profile_id !== profileId, + ) + this.profileVideos = this.profileVideos.filter( + (video) => video.profile_id !== profileId, + ) + if (this.viewedProfile?.id === profileId) this.viewedProfile = null + return true + }, + async loadComments(id: string): Promise { + const response = await nuiCall('fliptok:comments', { + id, + }) + this.comments = response.success && response.data ? response.data : [] + }, + async comment(id: string, body: string): Promise { + return nuiCall('fliptok:comment', { body, id }) + }, + async loadActivities(): Promise { + const response = await nuiCall('fliptok:activities') + this.activities = response.success && response.data ? response.data : [] + if (response.success) await nuiCall('fliptok:mark-activities') + }, + async loadReports(): Promise { + const response = await nuiCall('fliptok:admin-reports') + this.reports = response.success && response.data ? response.data : [] + return response.success + }, + async resolveReport( + id: string, + action: 'dismiss' | 'remove', + ): Promise { + const response = await nuiCall('fliptok:admin-resolve-report', { + action, + id, + }) + if (response.success) await this.loadReports() + return response.success + }, + }, +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 65d609e..237e506 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -40,6 +40,121 @@ const namespaceQueues = new Map>() const defaultLocales: LocaleTree = { Apps: { + fliptok: { + name: 'FlipTok', + loading: 'Loading FlipTok', + following: 'Following', + forYou: 'For You', + verified: 'Verified account', + originalSound: 'original sound', + save: 'Save', + home: 'Home', + discover: 'Discover', + create: 'Create', + activity: 'Activity', + profile: 'Profile', + emptyFeed: 'No videos yet', + emptyFeedBody: 'Follow creators or post the first FlipTok.', + searchPlaceholder: 'Search creators and videos', + noActivity: 'No activity yet', + followers: 'Followers', + videos: 'Videos', + emptyBio: 'No bio yet.', + editProfile: 'Edit profile', + newVideo: 'New FlipTok', + chooseVideo: 'Choose a video', + chooseVideoHint: 'Select one from Gallery', + changeVideo: 'Change', + captionPlaceholder: 'Write a caption...', + location: 'Add location', + whoCanWatch: 'Who can watch', + public: 'Everyone', + followersOnly: 'Followers', + private: 'Only me', + allowComments: 'Allow comments', + saveDraft: 'Drafts', + publishing: 'Posting...', + post: 'Post', + draftSaved: 'Draft saved.', + published: 'Your FlipTok is live.', + linkCopied: 'Video link copied.', + reported: 'Report submitted.', + blocked: 'Creator blocked.', + comments: 'Comments', + noComments: 'No comments yet', + addComment: 'Add comment...', + report: 'Report video', + reportReason: 'Reason', + reportDetails: 'Additional details (optional)', + submitReport: 'Submit report', + reportReasons: { + spam: 'Spam or misleading', + harassment: 'Harassment or bullying', + dangerous: 'Dangerous activity', + illegal: 'Illegal content', + other: 'Something else', + }, + block: 'Block creator', + follow: 'Follow', + unfollow: 'Following', + backToProfile: 'Back', + sounds: 'Sound', + chooseSound: 'Choose music', + originalOnly: 'Original sound only', + noMusic: 'No music tracks are configured.', + trimAndCover: 'Trim & cover', + trimStart: 'Start', + trimEnd: 'End', + coverFrame: 'Cover', + originalVolume: 'Original sound', + musicVolume: 'Music', + moderation: 'Moderation', + reports: 'Open reports', + noReports: 'No open reports', + removeVideo: 'Remove video', + dismissReport: 'Dismiss', + cancel: 'Cancel', + done: 'Done', + displayName: 'Name', + username: 'Username', + bio: 'Bio', + accountType: 'Account type', + accountTypes: { + person: 'Person', + business: 'Business', + organization: 'Organization', + media: 'Media', + event: 'Event', + }, + activityKinds: { + like: 'liked your video', + comment: 'commented on your video', + follow: 'started following you', + verified: 'verification changed', + }, + notifications: { + like: '{actor} liked your video.', + comment: '{actor} commented on your video.', + follow: '{actor} started following you.', + verified: 'Your FlipTok account is now verified.', + default: 'You have new FlipTok activity.', + }, + errors: { + invalid_video: 'Check the video details.', + invalid_media: 'Choose a video from this phone.', + invalid_comment: 'Enter a valid comment.', + comments_disabled: 'Comments are disabled.', + invalid_profile: 'Check your profile details.', + handle_taken: 'This username is already taken.', + video_not_found: 'This video is unavailable.', + rate_limited: 'Too many actions. Try again shortly.', + not_authenticated: 'Sign in to iFruit first.', + blocked: 'This account is blocked.', + not_authorized: 'You do not have moderation access.', + report_not_found: 'This report is no longer open.', + default: 'FlipTok could not complete the request.', + }, + }, darkchat: { name: 'DarkChat', newMessage: 'New DarkChat message from {sender}', @@ -1014,6 +1129,8 @@ const defaultLocales: LocaleTree = { portrait: 'Switch to portrait', photo: 'Photo', video: 'Video', + microphoneOn: 'Microphone on', + microphoneOff: 'Microphone muted', focusHelp: 'Space for movement', returnHelp: 'Space to return', uploading: '{count} uploading', @@ -1031,6 +1148,8 @@ const defaultLocales: LocaleTree = { invalid_upload: 'The upload could not be verified.', invalid_upload_token: 'The upload session is no longer valid.', missing_config: 'Camera uploads are not configured.', + microphone_unavailable: + 'Allow microphone access or mute the microphone before recording.', not_found: 'The media item no longer exists.', operation_in_progress: 'Another media operation is already in progress.', @@ -1488,6 +1607,7 @@ const defaultLocales: LocaleTree = { send: 'Send', start: 'Start', stop: 'Stop', + use: 'Use', }, Notifications: { now: 'now' }, LockScreen: { diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index fc9c11a..f08aad4 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -26,6 +26,7 @@ export type PhoneAppId = | 'neon-drop' | 'citymarkt' | 'local-pages' + | 'fliptok' export type LaunchablePhoneAppId = PhoneAppId diff --git a/frontend/src/types/fliptok.ts b/frontend/src/types/fliptok.ts new file mode 100644 index 0000000..29e790b --- /dev/null +++ b/frontend/src/types/fliptok.ts @@ -0,0 +1,97 @@ +export type FlipTokProfile = { + account_type: 'person' | 'business' | 'organization' | 'media' | 'event' + bio: string + display_name: string + followers: number + following: number + handle: string + id: number + is_following: boolean + is_owner: boolean + verified: boolean + video_count: number +} + +export type FlipTokVideo = { + caption: string + comment_count: number + comments_enabled: boolean + created_at: number + display_name: string + handle: string + id: string + is_following: boolean + is_liked: boolean + is_owner: boolean + is_saved: boolean + like_count: number + location: string + cover_time_ms: number + music_artist: string + music_title: string + music_track: string + music_url: string + music_volume: number + original_volume: number + profile_id: number + share_count: number + trim_end_ms: number | null + trim_start_ms: number + url: string + verified: boolean + view_count: number +} + +export type FlipTokMusicTrack = { + artist: string + id: string + title: string + url: string +} + +export type FlipTokReport = { + caption: string + created_at: number + creator_display_name: string + creator_handle: string + details: string + id: string + reason: 'spam' | 'harassment' | 'dangerous' | 'illegal' | 'other' + reporter_display_name: string + reporter_handle: string + url: string + video_id: string +} + +export type FlipTokProfilePage = { + profile: FlipTokProfile + videos: FlipTokVideo[] +} + +export type FlipTokComment = { + body: string + created_at: number + display_name: string + handle: string + id: string + profile_id: number + verified: boolean +} + +export type FlipTokActivity = { + created_at: number + display_name: string + handle: string + id: string + kind: 'like' | 'comment' | 'follow' | 'verified' + profile_id: number + read_at: string | null + verified: boolean + video_id: string | null +} + +export type FlipTokPage = { + hasMore: boolean + items: FlipTokVideo[] + offset: number +} diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 2118a05..0137885 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -68,6 +68,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< 'neon-drop': { enabled: true, sounds: true }, citymarkt: { enabled: true, sounds: true }, 'local-pages': { enabled: true, sounds: true }, + fliptok: { enabled: true, sounds: true }, camera: { enabled: true, sounds: true }, clock: { enabled: true, sounds: true }, calendar: { enabled: true, sounds: true }, diff --git a/frontend/src/views/apps/CameraApp.vue b/frontend/src/views/apps/CameraApp.vue index 43f7487..c1e604e 100644 --- a/frontend/src/views/apps/CameraApp.vue +++ b/frontend/src/views/apps/CameraApp.vue @@ -1,14 +1,10 @@ + +