From 5f7db5da6579431ca0ccf9a65c6ec1b17b93348e Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Thu, 8 Jan 2026 03:41:05 +0000 Subject: [PATCH 1/2] feat: add Electron platform support for channels --- .opencode/worktree-session-state.json | 4 + messages/en.json | 22 ++- planetscale/schema_replicate.sql | 1 + src/components/dashboard/AppSetting.vue | 137 +++++++++++++++--- src/components/dashboard/DeploymentBanner.vue | 29 +++- src/components/tables/LogTable.vue | 2 + .../[package].channel.[channel].devices.vue | 1 + .../[package].channel.[channel].history.vue | 1 + src/pages/app/[package].channel.[channel].vue | 7 + src/types/supabase.types.ts | 9 +- .../_backend/plugins/channel_self.ts | 8 +- .../functions/_backend/plugins/updates.ts | 2 +- .../_backend/private/create_device.ts | 2 +- .../_backend/public/build/request.ts | 6 +- .../functions/_backend/public/build/status.ts | 2 +- .../functions/_backend/utils/cloudflare.ts | 16 +- supabase/functions/_backend/utils/pg.ts | 32 +++- .../_backend/utils/postgres_schema.ts | 1 + supabase/functions/_backend/utils/supabase.ts | 2 +- .../_backend/utils/supabase.types.ts | 9 +- supabase/functions/_backend/utils/update.ts | 8 + .../20260108000000_add_electron_platform.sql | 24 +++ supabase/seed.sql | 20 +-- tests/channel_self.test.ts | 17 +++ tests/cli-channel.test.ts | 40 +++++ tests/updates.test.ts | 11 ++ 26 files changed, 345 insertions(+), 68 deletions(-) create mode 100644 supabase/migrations/20260108000000_add_electron_platform.sql diff --git a/.opencode/worktree-session-state.json b/.opencode/worktree-session-state.json index 0daccc0a27..18792f2991 100644 --- a/.opencode/worktree-session-state.json +++ b/.opencode/worktree-session-state.json @@ -23,6 +23,10 @@ "ses_46ba2f84effe4l1eoBWyYUSSox": { "sessionId": "ses_46ba2f84effe4l1eoBWyYUSSox", "createdAt": 1767720683481 + }, + "ses_464e51423ffeWTlQdvv79ZZB7q": { + "sessionId": "ses_464e51423ffeWTlQdvv79ZZB7q", + "createdAt": 1767833791500 } } } \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index 013ff14ac9..9d2fd62654 100644 --- a/messages/en.json +++ b/messages/en.json @@ -76,6 +76,7 @@ "action-disable-emulator": "Blocked - emulator disabled", "action-disable-device": "Blocked - device updates disabled", "action-disable-platform-android": "Blocked - Android platform disabled", + "action-disable-platform-electron": "Blocked - Electron platform disabled", "action-disable-platform-ios": "Blocked - iOS platform disabled", "action-download-10": "Download progress 10%", "action-download-20": "Download progress 20%", @@ -439,11 +440,13 @@ "channel-not-compatible-with-android": "This channel does not support Android.", "channel-not-compatible-with-channel": "Bundle not compatible with channel", "channel-not-compatible-with-channel-description": "You can check compatibility with (%)", + "channel-not-compatible-with-electron": "This channel does not support Electron.", "channel-not-compatible-with-ios": "This channel does not support iOS.", "channel-not-found": "Channel not found", "channel-not-found-description": "This channel could not be found. It might have been deleted or you might not have access to it.", "channel-platform-android": "Android only", - "channel-platform-both": "iOS & Android", + "channel-platform-both": "iOS & Android & Electron", + "channel-platform-electron": "Electron only", "channel-platform-ios": "iOS only", "channel-progressive-deploy": "Enable progressive deploy", "channels": "Channels", @@ -619,20 +622,23 @@ "default-download-channel-conflict": "Multiple defaults overlap the same platform. Update the selection to keep updates consistent.", "default-download-channel-dialog-info": "Pick which channels should serve updates for each platform.", "default-download-channel-doc-link": "Read the channel defaults guide", + "default-download-channel-electron-only-desc": "These channels currently deliver updates only to Electron apps.", + "default-download-channel-electron-only-empty": "No Electron-only channels yet.", + "default-download-channel-electron-only-title": "Electron-only channels", "default-download-channel-empty": "No default download channel yet", - "default-download-channel-help": "These channels deliver updates to devices. Prefer a single channel that supports both platforms.", + "default-download-channel-help": "These channels deliver updates to devices. Prefer a single channel that supports all platforms.", "default-download-channel-ios-only-desc": "These channels currently deliver updates only to iOS devices.", "default-download-channel-ios-only-empty": "No iOS-only channels yet.", "default-download-channel-ios-only-title": "iOS-only channels", "default-download-channel-more": "Search to pick other channels.", "default-download-channel-no-results": "No channels match your search.", - "default-download-channel-no-unified": "No channel currently supports both platforms.", + "default-download-channel-no-unified": "No channel currently supports all platforms.", "default-download-channel-search-placeholder": "Search channels...", "default-download-channel-split-desc": "Need different defaults? Toggle off to split by platform.", "default-download-channel-split-unavailable": "No single-platform channels available yet.", - "default-download-channel-unified-hint": "Only channels that support both iOS and Android appear here.", - "default-download-channel-use-unified": "Use one channel for iOS & Android", - "default-download-channel-use-unified-desc": "Recommended. Devices on both platforms receive updates from the same channel.", + "default-download-channel-unified-hint": "Only channels that support iOS, Android, and Electron appear here.", + "default-download-channel-use-unified": "Use one channel for all platforms", + "default-download-channel-use-unified-desc": "Recommended. Devices on all platforms receive updates from the same channel.", "default-upload-channel": "Default upload channel", "default-upload-channel-more": "Search to pick other channels.", "default-upload-channel-no-results": "No channels match your search.", @@ -1045,6 +1051,7 @@ "plans-super-only": "Only super admins are allowed to view plans and billing", "platform": "Platform", "platform-android": "Android", + "platform-electron": "Electron", "platform-ios": "iOS", "public-key-prefix": "Public key (first 4 chars)", "cli-version": "CLI version", @@ -1054,8 +1061,9 @@ "please-fill-the-captcha": "Please solve the captcha", "please-select-channel": "Please select channel", "please-select-channel-android": "Please select the default channel for Android.", + "please-select-channel-electron": "Please select the default channel for Electron.", "please-select-channel-ios": "Please select the default channel for iOS.", - "please-select-combined-channel": "Please select the channel to use for both iOS and Android.", + "please-select-combined-channel": "Please select the channel to use for all platforms.", "please-select-key-type": "please select apikey type", "please-select-permission": "Please select a permission", "please-select-user": "Please select a user", diff --git a/planetscale/schema_replicate.sql b/planetscale/schema_replicate.sql index 9225f9b72a..a02ed6cf4a 100644 --- a/planetscale/schema_replicate.sql +++ b/planetscale/schema_replicate.sql @@ -212,6 +212,7 @@ CREATE TABLE public.channels ( disable_auto_update_under_native boolean DEFAULT true NOT NULL, ios boolean DEFAULT true NOT NULL, android boolean DEFAULT true NOT NULL, + electron boolean DEFAULT true NOT NULL, allow_device_self_set boolean DEFAULT false NOT NULL, allow_emulator boolean DEFAULT true NOT NULL, allow_dev boolean DEFAULT true NOT NULL, diff --git a/src/components/dashboard/AppSetting.vue b/src/components/dashboard/AppSetting.vue index 77a8be0f0b..e6a19174ba 100644 --- a/src/components/dashboard/AppSetting.vue +++ b/src/components/dashboard/AppSetting.vue @@ -31,13 +31,14 @@ const organizationStore = useOrganizationStore() const transferAppIdInput = ref('') const selectedChannel = ref('') const uploadSearch = ref('') -const channels = ref>([]) -const selectedDownloadChannels = ref<{ ios: string, android: string }>({ ios: '', android: '' }) +const channels = ref>([]) +const selectedDownloadChannels = ref<{ ios: string, android: string, electron: string }>({ ios: '', android: '', electron: '' }) const splitDownloadDefaults = ref(false) const selectedCombinedChannel = ref('') const combinedSearch = ref('') const iosSearch = ref('') const androidSearch = ref('') +const electronSearch = ref('') onMounted(async () => { isLoading.value = true @@ -235,7 +236,7 @@ async function updateAllowPreview(newAllowPreview: boolean) { async function loadChannels() { const { data, error } = await supabase .from('channels') - .select('id, name, ios, android, public') + .select('id, name, ios, android, electron, public') .eq('app_id', props.appId) if (error) { @@ -249,9 +250,11 @@ async function loadChannels() { const iosChannels = computed(() => channels.value.filter(channel => channel.ios)) const androidChannels = computed(() => channels.value.filter(channel => channel.android)) -const combinedOptions = computed(() => channels.value.filter(channel => channel.ios && channel.android)) -const iosSingleOptions = computed(() => channels.value.filter(channel => channel.ios && !channel.android)) -const androidSingleOptions = computed(() => channels.value.filter(channel => channel.android && !channel.ios)) +const electronChannels = computed(() => channels.value.filter(channel => channel.electron)) +const combinedOptions = computed(() => channels.value.filter(channel => channel.ios && channel.android && channel.electron)) +const iosSingleOptions = computed(() => channels.value.filter(channel => channel.ios && !channel.android && !channel.electron)) +const androidSingleOptions = computed(() => channels.value.filter(channel => channel.android && !channel.ios && !channel.electron)) +const electronSingleOptions = computed(() => channels.value.filter(channel => channel.electron && !channel.ios && !channel.android)) const uploadChannelOptions = computed(() => { const seen = new Set() return channels.value @@ -352,8 +355,9 @@ async function setDefaultChannel() { const iosDefaultChannel = computed(() => channels.value.find(channel => channel.public && channel.ios) ?? null) const androidDefaultChannel = computed(() => channels.value.find(channel => channel.public && channel.android) ?? null) +const electronDefaultChannel = computed(() => channels.value.find(channel => channel.public && channel.electron) ?? null) -const canSplitDownloadDefaults = computed(() => iosSingleOptions.value.length > 0 || androidSingleOptions.value.length > 0) +const canSplitDownloadDefaults = computed(() => iosSingleOptions.value.length > 0 || androidSingleOptions.value.length > 0 || electronSingleOptions.value.length > 0) const hasCombinedOptions = computed(() => combinedOptions.value.length > 0) function filterChannels(list: Array<{ id: number, name: string }>, search: string) { @@ -375,18 +379,28 @@ const filteredAndroidSingleOptions = computed(() => filterChannels(androidSingle const visibleAndroidSingleOptions = computed(() => androidSearch.value.trim() ? filteredAndroidSingleOptions.value : filteredAndroidSingleOptions.value.slice(0, 3)) const androidHasHidden = computed(() => !androidSearch.value.trim() && filteredAndroidSingleOptions.value.length > 3) +const filteredElectronSingleOptions = computed(() => filterChannels(electronSingleOptions.value, electronSearch.value)) +const visibleElectronSingleOptions = computed(() => electronSearch.value.trim() ? filteredElectronSingleOptions.value : filteredElectronSingleOptions.value.slice(0, 3)) +const electronHasHidden = computed(() => !electronSearch.value.trim() && filteredElectronSingleOptions.value.length > 3) + const downloadChannelWarning = computed(() => { const iosDefaults = channels.value.filter(channel => channel.public && channel.ios) const androidDefaults = channels.value.filter(channel => channel.public && channel.android) + const electronDefaults = channels.value.filter(channel => channel.public && channel.electron) - if (iosDefaults.length > 1 || androidDefaults.length > 1) + if (iosDefaults.length > 1 || androidDefaults.length > 1 || electronDefaults.length > 1) return t('default-download-channel-conflict') const iosDefault = iosDefaults[0] const androidDefault = androidDefaults[0] + const electronDefault = electronDefaults[0] if (iosDefault && androidDefault && iosDefault.id !== androidDefault.id && (iosDefault.android || androidDefault.ios)) return t('default-download-channel-conflict') + if (iosDefault && electronDefault && iosDefault.id !== electronDefault.id && (iosDefault.electron || electronDefault.ios)) + return t('default-download-channel-conflict') + if (androidDefault && electronDefault && androidDefault.id !== electronDefault.id && (androidDefault.electron || electronDefault.android)) + return t('default-download-channel-conflict') return '' }) @@ -397,18 +411,23 @@ const downloadChannelLabel = computed(() => { const iosDefault = iosDefaultChannel.value const androidDefault = androidDefaultChannel.value + const electronDefault = electronDefaultChannel.value - if (!iosDefault && !androidDefault) + if (!iosDefault && !androidDefault && !electronDefault) return t('default-download-channel-empty') - if (iosDefault && androidDefault && iosDefault.id === androidDefault.id) { - return `${iosDefault.name} (${t('platform-ios')} & ${t('platform-android')})` + // Check if all platforms share the same channel + const allSame = iosDefault && androidDefault && electronDefault + && iosDefault.id === androidDefault.id && iosDefault.id === electronDefault.id + if (allSame) { + return `${iosDefault.name} (${t('platform-ios')} & ${t('platform-android')} & ${t('platform-electron')})` } const iosLabel = iosDefault ? iosDefault.name : t('not-set') const androidLabel = androidDefault ? androidDefault.name : t('not-set') + const electronLabel = electronDefault ? electronDefault.name : t('not-set') - return `${t('platform-ios')}: ${iosLabel} • ${t('platform-android')}: ${androidLabel}` + return `${t('platform-ios')}: ${iosLabel} • ${t('platform-android')}: ${androidLabel} • ${t('platform-electron')}: ${electronLabel}` }) async function openDefaultDownloadChannelDialog() { @@ -427,12 +446,16 @@ async function openDefaultDownloadChannelDialog() { combinedSearch.value = '' iosSearch.value = '' androidSearch.value = '' + electronSearch.value = '' const sameDefaultChannel = iosDefaultChannel.value && androidDefaultChannel.value + && electronDefaultChannel.value && iosDefaultChannel.value.id === androidDefaultChannel.value.id + && iosDefaultChannel.value.id === electronDefaultChannel.value.id && iosDefaultChannel.value.ios && iosDefaultChannel.value.android + && iosDefaultChannel.value.electron if (hasCombinedOptions.value && (!canSplitDownloadDefaults.value || sameDefaultChannel)) splitDownloadDefaults.value = false @@ -440,17 +463,22 @@ async function openDefaultDownloadChannelDialog() { splitDownloadDefaults.value = true else if (iosDefaultChannel.value && androidDefaultChannel.value && iosDefaultChannel.value.id !== androidDefaultChannel.value.id) splitDownloadDefaults.value = true + else if (iosDefaultChannel.value && electronDefaultChannel.value && iosDefaultChannel.value.id !== electronDefaultChannel.value.id) + splitDownloadDefaults.value = true + else if (androidDefaultChannel.value && electronDefaultChannel.value && androidDefaultChannel.value.id !== electronDefaultChannel.value.id) + splitDownloadDefaults.value = true else splitDownloadDefaults.value = !hasCombinedOptions.value const combinedFallback = combinedOptions.value.find(channel => - channel.id === iosDefaultChannel.value?.id || channel.id === androidDefaultChannel.value?.id, + channel.id === iosDefaultChannel.value?.id || channel.id === androidDefaultChannel.value?.id || channel.id === electronDefaultChannel.value?.id, ) ?? combinedOptions.value[0] ?? null selectedCombinedChannel.value = combinedFallback?.name ?? '' selectedDownloadChannels.value = { ios: iosSingleOptions.value.find(channel => channel.id === iosDefaultChannel.value?.id)?.name ?? '', android: androidSingleOptions.value.find(channel => channel.id === androidDefaultChannel.value?.id)?.name ?? '', + electron: electronSingleOptions.value.find(channel => channel.id === electronDefaultChannel.value?.id)?.name ?? '', } if (!splitDownloadDefaults.value && !selectedCombinedChannel.value && combinedFallback) @@ -471,6 +499,7 @@ async function openDefaultDownloadChannelDialog() { handler: async () => { let iosChannel: (typeof channels.value)[number] | null = null let androidChannel: (typeof channels.value)[number] | null = null + let electronChannel: (typeof channels.value)[number] | null = null if (!splitDownloadDefaults.value) { if (!selectedCombinedChannel.value) { @@ -484,10 +513,12 @@ async function openDefaultDownloadChannelDialog() { } iosChannel = combinedChannel androidChannel = combinedChannel + electronChannel = combinedChannel } else { const iosSelection = selectedDownloadChannels.value.ios const androidSelection = selectedDownloadChannels.value.android + const electronSelection = selectedDownloadChannels.value.electron if (!iosSelection && iosSingleOptions.value.length) { toast.error(t('please-select-channel-ios')) @@ -499,25 +530,38 @@ async function openDefaultDownloadChannelDialog() { return false } + if (!electronSelection && electronSingleOptions.value.length) { + toast.error(t('please-select-channel-electron')) + return false + } + iosChannel = iosSelection ? channels.value.find(channel => channel.name === iosSelection) ?? null : null androidChannel = androidSelection ? channels.value.find(channel => channel.name === androidSelection) ?? null : null + electronChannel = electronSelection + ? channels.value.find(channel => channel.name === electronSelection) ?? null + : null - if (iosChannel && (!iosChannel.ios || iosChannel.android)) { + if (iosChannel && (!iosChannel.ios || iosChannel.android || iosChannel.electron)) { toast.error(t('channel-not-compatible-with-ios')) return false } - if (androidChannel && (!androidChannel.android || androidChannel.ios)) { + if (androidChannel && (!androidChannel.android || androidChannel.ios || androidChannel.electron)) { toast.error(t('channel-not-compatible-with-android')) return false } + + if (electronChannel && (!electronChannel.electron || electronChannel.ios || electronChannel.android)) { + toast.error(t('channel-not-compatible-with-electron')) + return false + } } - const idsToEnable = Array.from(new Set([iosChannel?.id, androidChannel?.id].filter((id): id is number => typeof id === 'number'))) + const idsToEnable = Array.from(new Set([iosChannel?.id, androidChannel?.id, electronChannel?.id].filter((id): id is number => typeof id === 'number'))) if (idsToEnable.length > 0) { const { error } = await supabase @@ -563,12 +607,29 @@ async function openDefaultDownloadChannelDialog() { } } + if (electronChannels.value.length) { + const electronUpdate = supabase + .from('channels') + .update({ public: false }) + .eq('app_id', props.appId) + .eq('electron', true) + if (electronChannel) + electronUpdate.neq('id', electronChannel.id) + const { error } = await electronUpdate + if (error) { + toast.error(t('cannot-change-default-download-channel')) + console.error(error) + return false + } + } + const { error: hiddenError } = await supabase .from('channels') .update({ public: false }) .eq('app_id', props.appId) .eq('ios', false) .eq('android', false) + .eq('electron', false) if (hiddenError) { toast.error(t('cannot-change-default-download-channel')) @@ -600,7 +661,7 @@ function setUnifiedDownloadMode(unified: boolean) { } splitDownloadDefaults.value = false const fallback = combinedOptions.value.find(channel => channel.name === selectedCombinedChannel.value) - ?? combinedOptions.value.find(channel => channel.id === iosDefaultChannel.value?.id || channel.id === androidDefaultChannel.value?.id) + ?? combinedOptions.value.find(channel => channel.id === iosDefaultChannel.value?.id || channel.id === androidDefaultChannel.value?.id || channel.id === electronDefaultChannel.value?.id) ?? combinedOptions.value[0] selectedCombinedChannel.value = fallback?.name ?? '' } @@ -613,6 +674,7 @@ function setUnifiedDownloadMode(unified: boolean) { selectedDownloadChannels.value = { ios: iosSingleOptions.value.find(channel => channel.id === iosDefaultChannel.value?.id)?.name ?? '', android: androidSingleOptions.value.find(channel => channel.id === androidDefaultChannel.value?.id)?.name ?? '', + electron: electronSingleOptions.value.find(channel => channel.id === electronDefaultChannel.value?.id)?.name ?? '', } } } @@ -1267,6 +1329,47 @@ async function transferAppOwnership() { {{ t('default-download-channel-more') }}

+ +
+

+ {{ t('default-download-channel-electron-only-title') }} +

+

+ {{ t('default-download-channel-electron-only-desc') }} +

+ +
+ +
+
+ {{ electronSearch.trim() ? t('default-download-channel-no-results') : t('default-download-channel-electron-only-empty') }} +
+

+ {{ t('default-download-channel-more') }} +

+
diff --git a/src/components/dashboard/DeploymentBanner.vue b/src/components/dashboard/DeploymentBanner.vue index 8670cb5beb..a6e0c87435 100644 --- a/src/components/dashboard/DeploymentBanner.vue +++ b/src/components/dashboard/DeploymentBanner.vue @@ -68,7 +68,7 @@ const userRole = ref(null) const latestBundle = ref(null) type DefaultChannel = Pick< Database['public']['Tables']['channels']['Row'], - 'id' | 'name' | 'ios' | 'android' | 'public' | 'version' + 'id' | 'name' | 'ios' | 'android' | 'electron' | 'public' | 'version' > /** Default channels configured for downloads (public channels) */ @@ -78,7 +78,7 @@ const selectedChannelIds = ref([]) const deployDialogId = 'deploy-default-channels' -type PlatformKey = 'ios' | 'android' +type PlatformKey = 'ios' | 'android' | 'electron' interface DeployTarget { id: number name: string @@ -102,13 +102,14 @@ const deployTargets = computed(() => { return [] return defaultChannels.value - .filter(channel => channel.ios || channel.android) + .filter(channel => channel.ios || channel.android || channel.electron) .map(channel => ({ id: channel.id, name: channel.name, platforms: [ ...(channel.ios ? ['ios'] as PlatformKey[] : []), ...(channel.android ? ['android'] as PlatformKey[] : []), + ...(channel.electron ? ['electron'] as PlatformKey[] : []), ], needsDeploy: channel.version !== bundle.id, })) @@ -171,11 +172,11 @@ async function loadData() { // Step 2: Get default channels configuration (public download channels) const { data: publicChannels } = await supabase .from('channels') - .select('id, name, ios, android, public, version') + .select('id, name, ios, android, electron, public, version') .eq('app_id', props.appId) .eq('public', true) - defaultChannels.value = publicChannels?.filter(channel => channel.ios || channel.android) ?? [] + defaultChannels.value = publicChannels?.filter(channel => channel.ios || channel.android || channel.electron) ?? [] console.log('[DeploymentBanner] Default channels:', defaultChannels.value) // Step 3: Get latest bundle (excluding special bundle types) @@ -305,12 +306,24 @@ function isTargetSelected(id: number) { } function getPlatformLabel(platforms: PlatformKey[]) { - if (platforms.includes('ios') && platforms.includes('android')) + const hasIos = platforms.includes('ios') + const hasAndroid = platforms.includes('android') + const hasElectron = platforms.includes('electron') + + if (hasIos && hasAndroid && hasElectron) + return `${t('platform-ios')} & ${t('platform-android')} & ${t('platform-electron')}` + if (hasIos && hasAndroid) return `${t('platform-ios')} & ${t('platform-android')}` - if (platforms.includes('ios')) + if (hasIos && hasElectron) + return `${t('platform-ios')} & ${t('platform-electron')}` + if (hasAndroid && hasElectron) + return `${t('platform-android')} & ${t('platform-electron')}` + if (hasIos) return t('channel-platform-ios') - if (platforms.includes('android')) + if (hasAndroid) return t('channel-platform-android') + if (hasElectron) + return t('channel-platform-electron') return t('unknown') } diff --git a/src/components/tables/LogTable.vue b/src/components/tables/LogTable.vue index 0d72017d27..e1afe33c17 100644 --- a/src/components/tables/LogTable.vue +++ b/src/components/tables/LogTable.vue @@ -124,6 +124,7 @@ const actionFilters = ref>({ 'action-no-new': false, 'action-disable-platform-ios': false, 'action-disable-platform-android': false, + 'action-disable-platform-electron': false, 'action-disable-auto-update-to-major': false, 'action-cannot-update-via-private-channel': false, 'action-disable-auto-update-to-minor': false, @@ -189,6 +190,7 @@ const filterToAction: Record = { 'action-no-new': 'noNew', 'action-disable-platform-ios': 'disablePlatformIos', 'action-disable-platform-android': 'disablePlatformAndroid', + 'action-disable-platform-electron': 'disablePlatformElectron', 'action-disable-auto-update-to-major': 'disableAutoUpdateToMajor', 'action-cannot-update-via-private-channel': 'cannotUpdateViaPrivateChannel', 'action-disable-auto-update-to-minor': 'disableAutoUpdateToMinor', diff --git a/src/pages/app/[package].channel.[channel].devices.vue b/src/pages/app/[package].channel.[channel].devices.vue index 1228448be2..ad3d1011ac 100644 --- a/src/pages/app/[package].channel.[channel].devices.vue +++ b/src/pages/app/[package].channel.[channel].devices.vue @@ -219,6 +219,7 @@ async function getChannel() { disable_auto_update, ios, android, + electron, updated_at `) .eq('id', id.value) diff --git a/src/pages/app/[package].channel.[channel].history.vue b/src/pages/app/[package].channel.[channel].history.vue index eca13847df..97786a0376 100644 --- a/src/pages/app/[package].channel.[channel].history.vue +++ b/src/pages/app/[package].channel.[channel].history.vue @@ -65,6 +65,7 @@ async function getChannel() { disable_auto_update, ios, android, + electron, updated_at `) .eq('id', id.value) diff --git a/src/pages/app/[package].channel.[channel].vue b/src/pages/app/[package].channel.[channel].vue index 3023e35b16..724738aedd 100644 --- a/src/pages/app/[package].channel.[channel].vue +++ b/src/pages/app/[package].channel.[channel].vue @@ -98,6 +98,7 @@ async function getChannel(force = false) { disable_auto_update, ios, android, + electron, updated_at `) .eq('id', id.value) @@ -560,6 +561,12 @@ function openLink(url?: string): void { @change="saveChannelChange('android', !channel?.android)" /> + + + = ['mau'] const PLAN_ERROR = 'Cannot set channel, upgrade plan to continue to update' @@ -205,7 +205,7 @@ async function post(c: Context, drizzleClient: ReturnType 0) { const devicePlatform = body.platform as Database['public']['Enums']['platform_os'] - const finalChannel = mainChannel.find((channel: { name: string, ios: boolean, android: boolean }) => channel[devicePlatform]) + const finalChannel = mainChannel.find((channel: { name: string, ios: boolean, android: boolean, electron: boolean }) => channel[devicePlatform]) mainChannelName = (finalChannel !== undefined) ? finalChannel.name : null } @@ -363,7 +363,7 @@ async function put(c: Context, drizzleClient: ReturnType channel.name === defaultChannel) - : dataChannel.find((channel: { ios: boolean, android: boolean }) => channel[devicePlatform.data]) + : dataChannel.find((channel: { ios: boolean, android: boolean, electron: boolean }) => channel[devicePlatform.data]) if (!finalChannel) { return simpleError200(c, 'channel_not_found', 'Cannot find channel') @@ -507,7 +507,7 @@ async function listCompatibleChannels(c: Context, drizzleClient: ReturnType) + const channels = await getCompatibleChannelsPg(c, app_id, platform as 'ios' | 'android' | 'electron', is_emulator!, is_prod!, drizzleClient as ReturnType) if (!channels || channels.length === 0) { return c.json([]) diff --git a/supabase/functions/_backend/plugins/updates.ts b/supabase/functions/_backend/plugins/updates.ts index 21cbaa049b..aef0768e1a 100644 --- a/supabase/functions/_backend/plugins/updates.ts +++ b/supabase/functions/_backend/plugins/updates.ts @@ -44,7 +44,7 @@ const jsonRequestSchema = z.object({ is_emulator: z.boolean(), defaultChannel: z.optional(z.string()), is_prod: z.boolean(), - platform: z.literal(['android', 'ios'], { + platform: z.literal(['android', 'ios', 'electron'], { error: issue => issue.input === undefined ? MISSING_STRING_PLATFORM : INVALID_STRING_PLATFORM, }), plugin_version: z.string({ diff --git a/supabase/functions/_backend/private/create_device.ts b/supabase/functions/_backend/private/create_device.ts index 05e20e58a0..ed6337a41a 100644 --- a/supabase/functions/_backend/private/create_device.ts +++ b/supabase/functions/_backend/private/create_device.ts @@ -10,7 +10,7 @@ const bodySchema = z.object({ device_id: z.uuid(), app_id: z.string(), org_id: z.string(), - platform: z.enum(['ios', 'android']), + platform: z.enum(['ios', 'android', 'electron']), version_name: z.string(), }) diff --git a/supabase/functions/_backend/public/build/request.ts b/supabase/functions/_backend/public/build/request.ts index 24d6401e5b..fee3c128fe 100644 --- a/supabase/functions/_backend/public/build/request.ts +++ b/supabase/functions/_backend/public/build/request.ts @@ -7,7 +7,7 @@ import { getEnv } from '../../utils/utils.ts' export interface RequestBuildBody { app_id: string - platform: 'ios' | 'android' | 'both' + platform: 'ios' | 'android' | 'electron' | 'both' build_mode?: 'release' | 'debug' build_config?: Record credentials?: Record @@ -61,9 +61,9 @@ export async function requestBuild( throw simpleError('missing_parameter', 'platform is required') } - if (!['ios', 'android', 'both'].includes(platform)) { + if (!['ios', 'android', 'electron', 'both'].includes(platform)) { cloudlogErr({ requestId: c.get('requestId'), message: 'Invalid platform', platform }) - throw simpleError('invalid_parameter', 'platform must be ios or android') + throw simpleError('invalid_parameter', 'platform must be ios, android, or electron') } if (build_mode && !['release', 'debug'].includes(build_mode)) { diff --git a/supabase/functions/_backend/public/build/status.ts b/supabase/functions/_backend/public/build/status.ts index eb2d3d7d1b..5d49d4b85d 100644 --- a/supabase/functions/_backend/public/build/status.ts +++ b/supabase/functions/_backend/public/build/status.ts @@ -8,7 +8,7 @@ import { getEnv } from '../../utils/utils.ts' export interface BuildStatusParams { job_id: string app_id: string - platform: 'ios' | 'android' + platform: 'ios' | 'android' | 'electron' } interface BuilderStatusResponse { diff --git a/supabase/functions/_backend/utils/cloudflare.ts b/supabase/functions/_backend/utils/cloudflare.ts index a5cb61ceb9..c3f58f7903 100644 --- a/supabase/functions/_backend/utils/cloudflare.ts +++ b/supabase/functions/_backend/utils/cloudflare.ts @@ -188,8 +188,9 @@ export async function trackDevicesCF(c: Context, device: DeviceWithoutCreatedAt) // Write to Analytics Engine - this is the primary store now cloudlog({ requestId: c.get('requestId'), message: 'Writing to Analytics Engine DEVICE_INFO' }) - // Platform: 0 = android, 1 = ios - const platformValue = comparableDevice.platform?.toLowerCase() === 'ios' ? 1 : 0 + // Platform: 0 = android, 1 = ios, 2 = electron + const platformLower = comparableDevice.platform?.toLowerCase() + const platformValue = platformLower === 'ios' ? 1 : platformLower === 'electron' ? 2 : 0 c.env.DEVICE_INFO.writeDataPoint({ blobs: [ device.device_id, @@ -494,7 +495,7 @@ interface DeviceInfoCF { version_build: string default_channel: string key_id: string - platform: number // 0 = android, 1 = ios + platform: number // 0 = android, 1 = ios, 2 = electron is_prod: number // 0 or 1 is_emulator: number // 0 or 1 updated_at: string @@ -577,12 +578,13 @@ LIMIT ${limit + 1}` cloudlog({ requestId: c.get('requestId'), message: 'readDevicesCF res', resLength: res.length }) // Convert Analytics Engine results to Database device format + // Platform: 0 = android, 1 = ios, 2 = electron const results = res.map(row => ({ app_id: params.app_id, device_id: row.device_id, version: null, // version ID not stored in Analytics Engine version_name: row.version_name || null, - platform: row.platform === 1 ? 'ios' : 'android', + platform: row.platform === 1 ? 'ios' : row.platform === 2 ? 'electron' : 'android', plugin_version: row.plugin_version, os_version: row.os_version, version_build: row.version_build, @@ -1150,6 +1152,7 @@ export interface AdminPlatformOverview { total_bandwidth: number android_devices: number ios_devices: number + electron_devices: number total_devices: number period_start: string period_end: string @@ -1370,9 +1373,11 @@ export async function getAdminPlatformOverview( AND timestamp < toDateTime('${formatDateCF(end_date)}')` // Query 4: Device platform distribution from DEVICE_INFO + // Platform: 0 = android, 1 = ios, 2 = electron const platformQuery = `SELECT sum(if(double1 = 0, 1, 0)) AS android_devices, sum(if(double1 = 1, 1, 0)) AS ios_devices, + sum(if(double1 = 2, 1, 0)) AS electron_devices, COUNT(DISTINCT blob1) AS total_devices FROM device_info WHERE timestamp >= toDateTime('${formatDateCF(start_date)}') @@ -1397,7 +1402,7 @@ export async function getAdminPlatformOverview( c.env.DEVICE_USAGE ? runQueryToCFA<{ mau: number }>(c, mauQuery) : Promise.resolve([{ mau: 0 }]), c.env.APP_LOG ? runQueryToCFA<{ active_apps: number }>(c, appsQuery) : Promise.resolve([{ active_apps: 0 }]), c.env.BANDWIDTH_USAGE ? runQueryToCFA<{ total_bandwidth: number }>(c, bandwidthQuery) : Promise.resolve([{ total_bandwidth: 0 }]), - c.env.DEVICE_INFO ? runQueryToCFA<{ android_devices: number, ios_devices: number, total_devices: number }>(c, platformQuery) : Promise.resolve([{ android_devices: 0, ios_devices: 0, total_devices: 0 }]), + c.env.DEVICE_INFO ? runQueryToCFA<{ android_devices: number, ios_devices: number, electron_devices: number, total_devices: number }>(c, platformQuery) : Promise.resolve([{ android_devices: 0, ios_devices: 0, electron_devices: 0, total_devices: 0 }]), c.env.DEVICE_USAGE ? runQueryToCFA<{ active_orgs: number }>(c, orgsQuery) : Promise.resolve([{ active_orgs: 0 }]), c.env.VERSION_USAGE ? runQueryToCFA<{ installs: number, fails: number }>(c, successRateQuery) : Promise.resolve([{ installs: 0, fails: 0 }]), ]) @@ -1430,6 +1435,7 @@ export async function getAdminPlatformOverview( total_bandwidth: bandwidthResult[0]?.total_bandwidth || 0, android_devices: platformResult[0]?.android_devices || 0, ios_devices: platformResult[0]?.ios_devices || 0, + electron_devices: platformResult[0]?.electron_devices || 0, total_devices: platformResult[0]?.total_devices || 0, period_start: start_date, period_end: end_date, diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index 6b6ecaa21a..bb5caf7dca 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -257,6 +257,7 @@ function getSchemaUpdatesAlias(includeMetadata = false) { disable_auto_update: channelAlias.disable_auto_update, ios: channelAlias.ios, android: channelAlias.android, + electron: channelAlias.electron, allow_device_self_set: channelAlias.allow_device_self_set, public: channelAlias.public, } @@ -306,6 +307,14 @@ export function requestInfosChannelDevicePostgres( return channelDevice.then(data => data.at(0)) } +function getPlatformColumn(channelAlias: ReturnType['channelAlias'], platform: string) { + if (platform === 'android') + return channelAlias.android + if (platform === 'electron') + return channelAlias.electron + return channelAlias.ios +} + export function requestInfosChannelPostgres( c: Context, platform: string, @@ -316,7 +325,7 @@ export function requestInfosChannelPostgres( includeMetadata = false, ) { const { versionSelect, channelAlias, channelSelect, manifestSelect, versionAlias } = getSchemaUpdatesAlias(includeMetadata) - const platformQuery = platform === 'android' ? channelAlias.android : channelAlias.ios + const platformQuery = getPlatformColumn(channelAlias, platform) const baseSelect = { version: versionSelect, channels: channelSelect, @@ -560,13 +569,14 @@ export async function getMainChannelsPg( c: Context, appId: string, drizzleClient: ReturnType, -): Promise<{ name: string, ios: boolean, android: boolean }[]> { +): Promise<{ name: string, ios: boolean, android: boolean, electron: boolean }[]> { try { const channels = await drizzleClient .select({ name: schema.channels.name, ios: schema.channels.ios, android: schema.channels.android, + electron: schema.channels.electron, }) .from(schema.channels) .where(and( @@ -636,7 +646,7 @@ export async function getChannelsPg( appId: string, condition: { defaultChannel?: string } | { public: boolean }, drizzleClient: ReturnType, -): Promise<{ id: number, name: string, ios: boolean, android: boolean, public: boolean }[]> { +): Promise<{ id: number, name: string, ios: boolean, android: boolean, electron: boolean, public: boolean }[]> { try { const whereConditions = [eq(schema.channels.app_id, appId)] @@ -653,6 +663,7 @@ export async function getChannelsPg( name: schema.channels.name, ios: schema.channels.ios, android: schema.channels.android, + electron: schema.channels.electron, public: schema.channels.public, }) .from(schema.channels) @@ -692,14 +703,22 @@ export async function getAppByIdPg( } } +function getPlatformColumnForSchema(platform: 'ios' | 'android' | 'electron') { + if (platform === 'android') + return schema.channels.android + if (platform === 'electron') + return schema.channels.electron + return schema.channels.ios +} + export async function getCompatibleChannelsPg( c: Context, appId: string, - platform: 'ios' | 'android', + platform: 'ios' | 'android' | 'electron', isEmulator: boolean, isProd: boolean, drizzleClient: ReturnType, -): Promise<{ id: number, name: string, allow_device_self_set: boolean, allow_emulator: boolean, allow_device: boolean, allow_dev: boolean, allow_prod: boolean, ios: boolean, android: boolean, public: boolean }[]> { +): Promise<{ id: number, name: string, allow_device_self_set: boolean, allow_emulator: boolean, allow_device: boolean, allow_dev: boolean, allow_prod: boolean, ios: boolean, android: boolean, electron: boolean, public: boolean }[]> { try { const deviceCondition = isEmulator ? eq(schema.channels.allow_emulator, true) @@ -718,6 +737,7 @@ export async function getCompatibleChannelsPg( allow_prod: schema.channels.allow_prod, ios: schema.channels.ios, android: schema.channels.android, + electron: schema.channels.electron, public: schema.channels.public, }) .from(schema.channels) @@ -726,7 +746,7 @@ export async function getCompatibleChannelsPg( or(eq(schema.channels.allow_device_self_set, true), eq(schema.channels.public, true)), deviceCondition, buildCondition, - eq(platform === 'ios' ? schema.channels.ios : schema.channels.android, true), + eq(getPlatformColumnForSchema(platform), true), )) return channels } diff --git a/supabase/functions/_backend/utils/postgres_schema.ts b/supabase/functions/_backend/utils/postgres_schema.ts index 0f400d8afe..0f038f974f 100644 --- a/supabase/functions/_backend/utils/postgres_schema.ts +++ b/supabase/functions/_backend/utils/postgres_schema.ts @@ -74,6 +74,7 @@ export const channels = pgTable('channels', { disable_auto_update: disableUpdatePgEnum('disable_auto_update').default('major').notNull(), ios: boolean('ios').default(true).notNull(), android: boolean('android').notNull().default(true), + electron: boolean('electron').notNull().default(true), allow_device_self_set: boolean('allow_device_self_set').default(false).notNull(), allow_emulator: boolean('allow_emulator').notNull().default(true), allow_device: boolean('allow_device').notNull().default(true), diff --git a/supabase/functions/_backend/utils/supabase.ts b/supabase/functions/_backend/utils/supabase.ts index 4e93e08601..37ab902754 100644 --- a/supabase/functions/_backend/utils/supabase.ts +++ b/supabase/functions/_backend/utils/supabase.ts @@ -449,7 +449,7 @@ export async function recordBuildTime( orgId: string, userId: string, buildId: string, - platform: 'ios' | 'android', + platform: 'ios' | 'android' | 'electron', buildTimeSeconds: number, ): Promise { try { diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index bfa4dda66d..d7ac8ea09c 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -616,6 +616,7 @@ export type Database = { created_by: string disable_auto_update: Database["public"]["Enums"]["disable_update"] disable_auto_update_under_native: boolean + electron: boolean id: number ios: boolean name: string @@ -636,6 +637,7 @@ export type Database = { created_by: string disable_auto_update?: Database["public"]["Enums"]["disable_update"] disable_auto_update_under_native?: boolean + electron?: boolean id?: number ios?: boolean name: string @@ -656,6 +658,7 @@ export type Database = { created_by?: string disable_auto_update?: Database["public"]["Enums"]["disable_update"] disable_auto_update_under_native?: boolean + electron?: boolean id?: number ios?: boolean name?: string @@ -3051,7 +3054,7 @@ export type Database = { cron_task_type: "function" | "queue" | "function_queue" disable_update: "major" | "minor" | "patch" | "version_number" | "none" key_mode: "read" | "write" | "all" | "upload" - platform_os: "ios" | "android" + platform_os: "ios" | "android" | "electron" stats_action: | "delete" | "reset" @@ -3084,6 +3087,7 @@ export type Database = { | "noNew" | "disablePlatformIos" | "disablePlatformAndroid" + | "disablePlatformElectron" | "disableAutoUpdateToMajor" | "cannotUpdateViaPrivateChannel" | "disableAutoUpdateToMinor" @@ -3304,7 +3308,7 @@ export const Constants = { cron_task_type: ["function", "queue", "function_queue"], disable_update: ["major", "minor", "patch", "version_number", "none"], key_mode: ["read", "write", "all", "upload"], - platform_os: ["ios", "android"], + platform_os: ["ios", "android", "electron"], stats_action: [ "delete", "reset", @@ -3337,6 +3341,7 @@ export const Constants = { "noNew", "disablePlatformIos", "disablePlatformAndroid", + "disablePlatformElectron", "disableAutoUpdateToMajor", "cannotUpdateViaPrivateChannel", "disableAutoUpdateToMinor", diff --git a/supabase/functions/_backend/utils/update.ts b/supabase/functions/_backend/utils/update.ts index e93ff05b0e..5606265af0 100644 --- a/supabase/functions/_backend/utils/update.ts +++ b/supabase/functions/_backend/utils/update.ts @@ -226,6 +226,14 @@ export async function updateWithPG( old: version_name, }) } + if (!channelData.channels.electron && platform === 'electron') { + cloudlog({ requestId: c.get('requestId'), message: 'Cannot update, electron is disabled', id: device_id, date: new Date().toISOString() }) + await sendStatsAndDevice(c, device, [{ action: 'disablePlatformElectron', versionName: version.name }]) + return simpleError200(c, 'disabled_platform_electron', 'Cannot update, electron is disabled', { + version: version.name, + old: version_name, + }) + } if (!isInternalVersionName(version.name) && channelData?.channels.disable_auto_update === 'major' && parse(version.name).major > parse(version_build).major) { cloudlog({ requestId: c.get('requestId'), message: 'Cannot upgrade major version', id: device_id, date: new Date().toISOString() }) await sendStatsAndDevice(c, device, [{ action: 'disableAutoUpdateToMajor', versionName: version.name }]) diff --git a/supabase/migrations/20260108000000_add_electron_platform.sql b/supabase/migrations/20260108000000_add_electron_platform.sql new file mode 100644 index 0000000000..ff11363279 --- /dev/null +++ b/supabase/migrations/20260108000000_add_electron_platform.sql @@ -0,0 +1,24 @@ +-- Add Electron platform support +-- This migration adds 'electron' as a supported platform across the system + +-- Step 1: Add 'electron' to the platform_os enum +ALTER TYPE "public"."platform_os" ADD VALUE IF NOT EXISTS 'electron'; + +-- Step 2: Add electron column to channels table +ALTER TABLE "public"."channels" ADD COLUMN IF NOT EXISTS "electron" boolean DEFAULT true NOT NULL; + +-- Step 3: Create index for electron channel queries (similar to ios/android indexes) +CREATE INDEX IF NOT EXISTS "idx_channels_public_app_id_electron" ON "public"."channels" USING btree ("app_id") WHERE (("public" = true) AND ("electron" = true)); + +-- Step 4: Update build_requests platform check constraint to include 'electron' +ALTER TABLE "public"."build_requests" DROP CONSTRAINT IF EXISTS "build_requests_platform_check"; +ALTER TABLE "public"."build_requests" ADD CONSTRAINT "build_requests_platform_check" CHECK ((("platform")::text = ANY (ARRAY[('ios'::character varying)::text, ('android'::character varying)::text, ('both'::character varying)::text, ('electron'::character varying)::text]))); + +-- Step 5: Update build_logs platform check constraint to include 'electron' +ALTER TABLE "public"."build_logs" DROP CONSTRAINT IF EXISTS "build_logs_platform_check"; +ALTER TABLE "public"."build_logs" ADD CONSTRAINT "build_logs_platform_check" CHECK ((("platform")::text = ANY (ARRAY[('ios'::character varying)::text, ('android'::character varying)::text, ('electron'::character varying)::text]))); + +-- Step 6: Add 'disablePlatformElectron' to stats_action enum +ALTER TYPE "public"."stats_action" ADD VALUE IF NOT EXISTS 'disablePlatformElectron'; + +-- Note: Most platform validation happens in the backend code, so this is mainly for database-level constraints diff --git a/supabase/seed.sql b/supabase/seed.sql index da08e89a15..36ea89574c 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -534,11 +534,11 @@ BEGIN (10, NOW(), 'com.demoadmin.app', NOW(), 'admin123', 1500000), (13, NOW(), 'com.stats.app', NOW(), 'stats123', 850000); - INSERT INTO "public"."channels" ("id", "created_at", "name", "app_id", "version", "updated_at", "public", "disable_auto_update_under_native", "disable_auto_update", "ios", "android", "allow_device_self_set", "allow_emulator", "allow_device", "allow_dev", "allow_prod", "created_by") VALUES - (1, NOW(), 'production', 'com.demo.app', 3, NOW(), 't', 't', 'major'::"public"."disable_update", 'f', 't', 't', 't', 't', 't', 't', '6aa76066-55ef-4238-ade6-0b32334a4097'::uuid), - (2, NOW(), 'no_access', 'com.demo.app', 5, NOW(), 'f', 't', 'major'::"public"."disable_update", 't', 't', 't', 't', 't', 't', 't', '6aa76066-55ef-4238-ade6-0b32334a4097'::uuid), - (3, NOW(), 'two_default', 'com.demo.app', 3, NOW(), 't', 't', 'major'::"public"."disable_update", 't', 'f', 't', 't', 't', 't', 't', '6aa76066-55ef-4238-ade6-0b32334a4097'::uuid), - (4, NOW(), 'production', 'com.stats.app', 13, NOW(), 't', 't', 'major'::"public"."disable_update", 'f', 't', 't', 't', 't', 't', 't', '7a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d'::uuid); + INSERT INTO "public"."channels" ("id", "created_at", "name", "app_id", "version", "updated_at", "public", "disable_auto_update_under_native", "disable_auto_update", "ios", "android", "electron", "allow_device_self_set", "allow_emulator", "allow_device", "allow_dev", "allow_prod", "created_by") VALUES + (1, NOW(), 'production', 'com.demo.app', 3, NOW(), 't', 't', 'major'::"public"."disable_update", 'f', 't', 't', 't', 't', 't', 't', 't', '6aa76066-55ef-4238-ade6-0b32334a4097'::uuid), + (2, NOW(), 'no_access', 'com.demo.app', 5, NOW(), 'f', 't', 'major'::"public"."disable_update", 't', 't', 't', 't', 't', 't', 't', 't', '6aa76066-55ef-4238-ade6-0b32334a4097'::uuid), + (3, NOW(), 'two_default', 'com.demo.app', 3, NOW(), 't', 't', 'major'::"public"."disable_update", 't', 'f', 't', 't', 't', 't', 't', 't', '6aa76066-55ef-4238-ade6-0b32334a4097'::uuid), + (4, NOW(), 'production', 'com.stats.app', 13, NOW(), 't', 't', 'major'::"public"."disable_update", 'f', 't', 't', 't', 't', 't', 't', 't', '7a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d'::uuid); INSERT INTO "public"."deploy_history" ("id", "created_at", "updated_at", "channel_id", "app_id", "version_id", "deployed_at", "owner_org", "created_by") VALUES (1, NOW() - interval '15 days', NOW() - interval '15 days', 1, 'com.demo.app', 3, NOW() - interval '15 days', '046a36ac-e03c-4590-9257-bd6c9dba9ee8'::uuid, '6aa76066-55ef-4238-ade6-0b32334a4097'::uuid), @@ -851,12 +851,12 @@ BEGIN SELECT MAX(CASE WHEN name='builtin' THEN id END), MAX(CASE WHEN name='unknown' THEN id END), MAX(CASE WHEN name='1.0.1' THEN id END), MAX(CASE WHEN name='1.0.0' THEN id END), MAX(CASE WHEN name='1.361.0' THEN id END), MAX(CASE WHEN name='1.360.0' THEN id END), MAX(CASE WHEN name='1.359.0' THEN id END) INTO builtin_version_id, unknown_version_id, v1_0_1_version_id, v1_0_0_version_id, v1_361_0_version_id, v1_360_0_version_id, v1_359_0_version_id FROM version_inserts; WITH channel_inserts AS ( - INSERT INTO public.channels (created_at, name, app_id, version, updated_at, public, disable_auto_update_under_native, disable_auto_update, ios, android, allow_device_self_set, allow_emulator, allow_device, allow_dev, allow_prod, created_by, owner_org) + INSERT INTO public.channels (created_at, name, app_id, version, updated_at, public, disable_auto_update_under_native, disable_auto_update, ios, android, electron, allow_device_self_set, allow_emulator, allow_device, allow_dev, allow_prod, created_by, owner_org) VALUES - (NOW(), 'production', p_app_id, v1_0_0_version_id, NOW(), 't', 't', 'major'::public.disable_update, 'f', 't', 't', 't', 't', 't', 't', user_id, org_id), - (NOW(), 'beta', p_app_id, v1_361_0_version_id, NOW(), 'f', 't', 'major'::public.disable_update, 't', 't', 't', 't', 't', 't', 't', user_id, org_id), - (NOW(), 'development', p_app_id, v1_359_0_version_id, NOW(), 't', 't', 'major'::public.disable_update, 't', 'f', 't', 't', 't', 't', 't', user_id, org_id), - (NOW(), 'no_access', p_app_id, v1_361_0_version_id, NOW(), 'f', 't', 'major'::public.disable_update, 'f', 'f', 't', 't', 't', 't', 't', user_id, org_id) + (NOW(), 'production', p_app_id, v1_0_0_version_id, NOW(), 't', 't', 'major'::public.disable_update, 'f', 't', 't', 't', 't', 't', 't', 't', user_id, org_id), + (NOW(), 'beta', p_app_id, v1_361_0_version_id, NOW(), 'f', 't', 'major'::public.disable_update, 't', 't', 't', 't', 't', 't', 't', 't', user_id, org_id), + (NOW(), 'development', p_app_id, v1_359_0_version_id, NOW(), 't', 't', 'major'::public.disable_update, 't', 'f', 't', 't', 't', 't', 't', 't', user_id, org_id), + (NOW(), 'no_access', p_app_id, v1_361_0_version_id, NOW(), 'f', 't', 'major'::public.disable_update, 'f', 'f', 't', 't', 't', 't', 't', 't', user_id, org_id) RETURNING id, name ) SELECT MAX(CASE WHEN name='production' THEN id END), MAX(CASE WHEN name='beta' THEN id END), MAX(CASE WHEN name='development' THEN id END), MAX(CASE WHEN name='no_access' THEN id END) diff --git a/tests/channel_self.test.ts b/tests/channel_self.test.ts index 73dcd0389c..bef70e759d 100644 --- a/tests/channel_self.test.ts +++ b/tests/channel_self.test.ts @@ -340,6 +340,23 @@ describe('[GET] /channel_self tests', () => { expect(json).toHaveLength(2) }) + it('[GET] should return compatible channels for Electron', async () => { + await resetAndSeedAppData(APPNAME) + + const data = getBaseData(APPNAME) + data.platform = 'electron' + data.is_emulator = false + data.is_prod = true + const response = await fetchGetChannels(data as any) + + expect(response.ok).toBe(true) + const json = await response.json() as ChannelsListResponse + + expect(json).toBeDefined() + expect(Array.isArray(json)).toBe(true) + // Electron should get channels that have electron=true + }) + it('[GET] should return public channels matching platform/device when self-set is disabled', async () => { await resetAndSeedAppData(APPNAME) diff --git a/tests/cli-channel.test.ts b/tests/cli-channel.test.ts index 23c5987326..d4ef14be32 100644 --- a/tests/cli-channel.test.ts +++ b/tests/cli-channel.test.ts @@ -306,6 +306,46 @@ describe('tests CLI channel commands', () => { expect(data?.ios).toBe(true) expect(data?.android).toBe(true) }) + + it.concurrent('should set channel platform to electron', async () => { + const testChannelName = generateChannelName() + await createChannel(testChannelName, APPNAME) + + const result = await createTestSDK().updateChannel({ channelId: testChannelName, appId: APPNAME, bundle: undefined, ...{ electron: true } }) + expect(result.success).toBe(true) + + // Verify in database + const { data, error } = await getSupabaseClient() + .from('channels') + .select('*') + .eq('name', testChannelName) + .eq('app_id', APPNAME) + .single() + .throwOnError() + expect(error).toBeNull() + expect(data?.electron).toBe(true) + }) + + it.concurrent('should set all platforms simultaneously', async () => { + const testChannelName = generateChannelName() + await createChannel(testChannelName, APPNAME) + + const result = await createTestSDK().updateChannel({ channelId: testChannelName, appId: APPNAME, bundle: undefined, ...{ ios: true, android: true, electron: true } }) + expect(result.success).toBe(true) + + // Verify in database + const { data, error } = await getSupabaseClient() + .from('channels') + .select('*') + .eq('name', testChannelName) + .eq('app_id', APPNAME) + .single() + .throwOnError() + expect(error).toBeNull() + expect(data?.ios).toBe(true) + expect(data?.android).toBe(true) + expect(data?.electron).toBe(true) + }) }) describe.concurrent('channel self-assign operations', () => { diff --git a/tests/updates.test.ts b/tests/updates.test.ts index 4dc55433fe..001c946282 100644 --- a/tests/updates.test.ts +++ b/tests/updates.test.ts @@ -330,6 +330,17 @@ describe('[POST] /updates invalid data', () => { expect(json.error).toBe('missing_device_id') }) + it('electron platform is accepted', async () => { + const baseData = { platform: 'electron' } as any + + const response = await postUpdate(baseData) + expect(response.status).toBe(400) + + const json = await response.json() + // Should be missing_device_id, not invalid_platform, proving electron is accepted + expect(json.error).toBe('missing_device_id') + }) + it('device_id and app_id combination not found', async () => { const baseData = getBaseData(APP_NAME_UPDATE) baseData.device_id = '00000000-0000-0000-1234-000000000000' From 96ff14d6bc036fc0b7950d3706a81c0e68e09de8 Mon Sep 17 00:00:00 2001 From: Martin DONADIEU Date: Thu, 8 Jan 2026 04:06:56 +0000 Subject: [PATCH 2/2] Update supabase/migrations/20260108000000_add_electron_platform.sql Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- supabase/migrations/20260108000000_add_electron_platform.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/migrations/20260108000000_add_electron_platform.sql b/supabase/migrations/20260108000000_add_electron_platform.sql index ff11363279..1bd6cefd00 100644 --- a/supabase/migrations/20260108000000_add_electron_platform.sql +++ b/supabase/migrations/20260108000000_add_electron_platform.sql @@ -8,7 +8,7 @@ ALTER TYPE "public"."platform_os" ADD VALUE IF NOT EXISTS 'electron'; ALTER TABLE "public"."channels" ADD COLUMN IF NOT EXISTS "electron" boolean DEFAULT true NOT NULL; -- Step 3: Create index for electron channel queries (similar to ios/android indexes) -CREATE INDEX IF NOT EXISTS "idx_channels_public_app_id_electron" ON "public"."channels" USING btree ("app_id") WHERE (("public" = true) AND ("electron" = true)); +CREATE INDEX IF NOT EXISTS "idx_channels_public_app_id_electron" ON "public"."channels" USING btree ("public", "app_id", "electron"); -- Step 4: Update build_requests platform check constraint to include 'electron' ALTER TABLE "public"."build_requests" DROP CONSTRAINT IF EXISTS "build_requests_platform_check";