Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/ui/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"app-store.category.media": "Media",
"app-store.category.networking": "Networking",
"app-store.category.social": "Social",
"app-store.description": "Your app update settings",
"app-store.description": "Registries, preferences",
"app-store.discover.temporarily-unavailable-description": "Browse categories above or search to find apps",
"app-store.discover.temporarily-unavailable-title": "Featured content temporarily unavailable",
"app-store.menu.community-app-stores": "Community App Stores",
Expand Down Expand Up @@ -961,6 +961,14 @@
"search": "Search",
"settings": "Settings",
"settings.app-store-preferences.title": "App Store Preferences",
"settings.app-store-preferences.registry-credentials.title": "Private Registry Credentials",
"settings.app-store-preferences.registry-credentials.description": "Login credentials for private container registries (e.g. ghcr.io). Used when installing community apps that require private images.",
"settings.app-store-preferences.registry-credentials.add-button": "Add registry",
"settings.app-store-preferences.registry-credentials.registry-placeholder": "Registry host (e.g. ghcr.io)",
"settings.app-store-preferences.registry-credentials.username-placeholder": "Username",
"settings.app-store-preferences.registry-credentials.token-placeholder": "Token / Password",
"settings.app-store-preferences.registry-credentials.save": "Save",
"settings.app-store-preferences.registry-credentials.remove": "Remove",
"settings.contact-support": "Need help? <linked>Contact support.</linked>",
"settings.file-sharing": "File sharing",
"settings.file-sharing.add-folder": "Add",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,71 +1,115 @@
import {useState} from 'react'
import {TbChevronRight} from 'react-icons/tb'
import {useTranslation} from 'react-i18next'
import {TbLock, TbPlus, TbTrash} from 'react-icons/tb'

import {ChevronDown} from '@/components/chevron-down'
import {Button} from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {Input, PasswordInput} from '@/components/ui/input'
import {listClass, listItemClass} from '@/components/ui/list'
import {SegmentedControl} from '@/components/ui/segmented-control'
import {Switch} from '@/components/ui/switch'
import {trpcReact} from '@/trpc/trpc'

export function AppStorePreferencesContent() {
const tabs = [
{id: 'auto-update', label: 'Auto-update'},
{id: 'notifications', label: 'Notifications'},
{id: 'uninstall', label: 'Uninstall'},
]
const [activeTab, setActiveTab] = useState(tabs[0].id)
const {t} = useTranslation()
const utils = trpcReact.useUtils()

const credentialsQ = trpcReact.appStore.getRegistryCredentials.useQuery()
const setMut = trpcReact.appStore.setRegistryCredential.useMutation({
onSuccess: () => utils.appStore.getRegistryCredentials.invalidate(),
})
const removeMut = trpcReact.appStore.removeRegistryCredential.useMutation({
onSuccess: () => utils.appStore.getRegistryCredentials.invalidate(),
})

const [showForm, setShowForm] = useState(false)
const [registry, setRegistry] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')

const resetForm = () => {
setRegistry('')
setUsername('')
setPassword('')
setShowForm(false)
}

const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
await setMut.mutateAsync({registry: registry.trim(), username: username.trim(), password})
resetForm()
}

const credentials = credentialsQ.data ?? []

return (
<>
<div className={listClass}>
<label className={listItemClass}>
<span>Allow a specific function</span>
<Switch />
</label>
<div className={listItemClass}>
Single value selector
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size='sm'>
Value label
<ChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuCheckboxItem checked>English</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem>French</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<label className={listItemClass}>
Multi-level setting
<TbChevronRight />
</label>
<div className='flex flex-col gap-4'>
<div>
<h3 className='flex items-center gap-2 text-15 font-semibold'>
<TbLock className='h-4 w-4 opacity-60' />
{t('settings.app-store-preferences.registry-credentials.title')}
</h3>
<p className='mt-1 text-13 text-white/40'>
{t('settings.app-store-preferences.registry-credentials.description')}
</p>
</div>
<SegmentedControl size='lg' tabs={tabs} value={activeTab} onValueChange={setActiveTab} />
<div className={listClass}>
<label className={listItemClass}>Auto-update all apps</label>
</div>
<div className={listClass}>
<div className={listItemClass}>
<span>Lighting node</span>
<Button size='sm' className='text-destructive2-lightest'>
Uninstall
</Button>
</div>
<div className={listItemClass}>
<span>Lighting node</span>
<Button size='sm' className='text-destructive2-lightest'>
Uninstall
</Button>

{credentials.length > 0 && (
<div className={listClass}>
{credentials.map(({registry: reg}) => (
<div key={reg} className={listItemClass}>
<span className='truncate font-mono text-13'>{reg}</span>
<Button
size='sm'
className='text-destructive2-lightest shrink-0'
disabled={removeMut.isPending}
onClick={() => removeMut.mutate({registry: reg})}
>
<TbTrash className='h-3.5 w-3.5' />
{t('settings.app-store-preferences.registry-credentials.remove')}
</Button>
</div>
))}
</div>
</div>
</>
)}

{!showForm && (
<Button size='sm' className='self-start' onClick={() => setShowForm(true)}>
<TbPlus className='h-3.5 w-3.5' />
{t('settings.app-store-preferences.registry-credentials.add-button')}
</Button>
)}

{showForm && (
<form onSubmit={handleSave} className='flex flex-col gap-3'>
<Input
placeholder={t('settings.app-store-preferences.registry-credentials.registry-placeholder')}
value={registry}
onValueChange={setRegistry}
autoFocus
/>
<Input
placeholder={t('settings.app-store-preferences.registry-credentials.username-placeholder')}
value={username}
onValueChange={setUsername}
/>
<PasswordInput
placeholder={t('settings.app-store-preferences.registry-credentials.token-placeholder')}
value={password}
onValueChange={setPassword}
/>
<div className='flex gap-2'>
<Button
type='submit'
size='sm'
variant='primary'
disabled={!registry.trim() || !username.trim() || !password || setMut.isPending}
>
{t('settings.app-store-preferences.registry-credentials.save')}
</Button>
<Button type='button' size='sm' onClick={resetForm}>
{t('cancel')}
</Button>
</div>
</form>
)}
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
TbServer,
TbSettingsMinus,
TbShare,
TbShoppingBag,
TbTool,
TbUser,
TbWifi,
Expand Down Expand Up @@ -210,12 +211,12 @@ export function SettingsContentMobile() {
description={t('backups-description')}
onClick={() => navigate('backups')}
/>
{/* <ListRowMobile
<ListRowMobile
icon={TbShoppingBag}
title={t('app-store.title')}
description={t('app-store.description')}
onClick={() => navigate(linkToDialog('app-store-preferences'))}
/> */}
/>
<ListRowMobile
icon={TbTool}
title={t('troubleshoot')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {useEffect, useState} from 'react'
import {useTranslation} from 'react-i18next'
import {FaRegSave} from 'react-icons/fa'
import {
RiEqualizerLine,
RiExpandRightFill,
RiKeyLine,
RiLogoutCircleRLine,
Expand Down Expand Up @@ -261,11 +262,11 @@ export function SettingsContent() {
</DropdownMenu>
</div>
</ListRow>
{/* <ListRow title={t('app-store.title')} description={t('app-store.description')}>
<ListRow title={t('app-store.title')} description={t('app-store.description')}>
<IconButton icon={RiEqualizerLine} onClick={() => navigate(linkToDialog('app-store-preferences'))}>
{t('preferences')}
</IconButton>
</ListRow> */}
</ListRow>
<ListRow title={t('troubleshoot')} description={t('troubleshoot-description')}>
<IconButton icon={TbTool} onClick={() => navigate('troubleshoot')}>
{t('troubleshoot')}
Expand Down
4 changes: 2 additions & 2 deletions packages/umbreld/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 51 additions & 0 deletions packages/umbreld/source/modules/apps/routes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import z from 'zod'
import fse from 'fs-extra'

import {router, privateProcedure} from '../server/trpc/trpc.js'

const DOCKER_CONFIG_PATH = '/root/.docker/config.json'

export const appStore = router({
// Returns the app store registry
registry: privateProcedure.query(async ({ctx}) => ctx.appStore.registry()),
Expand All @@ -23,6 +26,54 @@ export const appStore = router({
}),
)
.mutation(async ({ctx, input}) => ctx.appStore.removeRepository(input.url)),

// List saved private registry hostnames (no passwords returned)
getRegistryCredentials: privateProcedure.query(async () => {
try {
const config = await fse.readJson(DOCKER_CONFIG_PATH)
const auths: Record<string, {auth?: string}> = config?.auths ?? {}
return Object.keys(auths)
.filter((registry) => !!auths[registry]?.auth)
.map((registry) => ({registry}))
} catch {
return []
}
}),

// Save (add or update) credentials for a private registry
setRegistryCredential: privateProcedure
.input(
z.object({
registry: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
}),
)
.mutation(async ({input}) => {
const config = (await fse.pathExists(DOCKER_CONFIG_PATH))
? await fse.readJson(DOCKER_CONFIG_PATH)
: {}
config.auths = config.auths ?? {}
config.auths[input.registry] = {
auth: Buffer.from(`${input.username}:${input.password}`).toString('base64'),
}
await fse.outputJson(DOCKER_CONFIG_PATH, config, {spaces: 2})
return true
}),

// Remove credentials for a registry
removeRegistryCredential: privateProcedure
.input(z.object({registry: z.string().min(1)}))
.mutation(async ({input}) => {
try {
const config = await fse.readJson(DOCKER_CONFIG_PATH)
if (config?.auths?.[input.registry]) {
delete config.auths[input.registry]
await fse.outputJson(DOCKER_CONFIG_PATH, config, {spaces: 2})
}
} catch {}
return true
}),
})

export const apps = router({
Expand Down
25 changes: 24 additions & 1 deletion packages/umbreld/source/modules/utilities/docker-pull.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,40 @@
import Dockerode from 'dockerode'
import fse from 'fs-extra'

const docker = new Dockerode()

const DOWNLOADING_PERCENT = 0.75
const EXTRACTING_PERCENT = 0.25

async function getAuthConfig(image: string): Promise<object | undefined> {
try {
const config = await fse.readJson('/root/.docker/config.json')
const firstSegment = image.split('/')[0]
const registry = firstSegment.includes('.') || firstSegment.includes(':')
? firstSegment
: 'https://index.docker.io/v1/'
const entry = config?.auths?.[registry]
if (entry?.auth) {
const decoded = Buffer.from(entry.auth, 'base64').toString()
const colonIndex = decoded.indexOf(':')
const username = decoded.slice(0, colonIndex)
const password = decoded.slice(colonIndex + 1)
const serveraddress = registry.startsWith('http') ? registry : `https://${registry}`
return {username, password, serveraddress}
}
} catch {}
return undefined
}

export async function pull(
image: string,
updateProgress: (progress: number) => void,
handleAlreadyDownloaded: () => void,
) {
const authconfig = await getAuthConfig(image)

return new Promise((resolve, reject) => {
docker.pull(image, (error: Error, stream: NodeJS.ReadableStream) => {
docker.pull(image, {authconfig}, (error: Error, stream: NodeJS.ReadableStream) => {
if (error) return reject(error)

const layerProgress: Record<string, number> = {}
Expand Down