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";