mirror of
				https://github.com/immich-app/immich
				synced 2025-10-17 18:19:27 +00:00 
			
		
		
		
	feat(web): Download links and Obtainium link generator on Utilities page and onboarding (#20589)
This commit is contained in:
		
							parent
							
								
									3163afd24a
								
							
						
					
					
						commit
						cc1cd299f3
					
				
					 8 changed files with 226 additions and 3 deletions
				
			
		| 
						 | 
				
			
			@ -1,5 +1,6 @@
 | 
			
		|||
The mobile app can be downloaded from the following places:
 | 
			
		||||
 | 
			
		||||
- Obtainium: You can get your Obtainium config link from the [Utilities page of your Immich server](https://my.immich.app/utilities).
 | 
			
		||||
- [Google Play Store](https://play.google.com/store/apps/details?id=app.alextran.immich)
 | 
			
		||||
- [Apple App Store](https://apps.apple.com/us/app/immich/id1613945652)
 | 
			
		||||
- [F-Droid](https://f-droid.org/packages/app.alextran.immich)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -38,6 +38,7 @@ test.describe('Registration', () => {
 | 
			
		|||
    await page.getByRole('button', { name: 'User Privacy' }).click();
 | 
			
		||||
    await page.getByRole('button', { name: 'Storage Template' }).click();
 | 
			
		||||
    await page.getByRole('button', { name: 'Backups' }).click();
 | 
			
		||||
    await page.getByRole('button', { name: 'Mobile App' }).click();
 | 
			
		||||
    await page.getByRole('button', { name: 'Done' }).click();
 | 
			
		||||
 | 
			
		||||
    // success
 | 
			
		||||
| 
						 | 
				
			
			@ -85,6 +86,7 @@ test.describe('Registration', () => {
 | 
			
		|||
    await page.getByRole('button', { name: 'Theme' }).click();
 | 
			
		||||
    await page.getByRole('button', { name: 'Language' }).click();
 | 
			
		||||
    await page.getByRole('button', { name: 'User Privacy' }).click();
 | 
			
		||||
    await page.getByRole('button', { name: 'Mobile App' }).click();
 | 
			
		||||
    await page.getByRole('button', { name: 'Done' }).click();
 | 
			
		||||
 | 
			
		||||
    // success
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -468,9 +468,11 @@
 | 
			
		|||
  "api_key_description": "This value will only be shown once. Please be sure to copy it before closing the window.",
 | 
			
		||||
  "api_key_empty": "Your API Key name shouldn't be empty",
 | 
			
		||||
  "api_keys": "API Keys",
 | 
			
		||||
  "app_architecture_variant": "Variant (Architecture)",
 | 
			
		||||
  "app_bar_signout_dialog_content": "Are you sure you want to sign out?",
 | 
			
		||||
  "app_bar_signout_dialog_ok": "Yes",
 | 
			
		||||
  "app_bar_signout_dialog_title": "Sign out",
 | 
			
		||||
  "app_download_links": "App Download Links",
 | 
			
		||||
  "app_settings": "App Settings",
 | 
			
		||||
  "appears_in": "Appears in",
 | 
			
		||||
  "apply_count": "Apply ({count, number})",
 | 
			
		||||
| 
						 | 
				
			
			@ -1348,6 +1350,8 @@
 | 
			
		|||
  "minute": "Minute",
 | 
			
		||||
  "minutes": "Minutes",
 | 
			
		||||
  "missing": "Missing",
 | 
			
		||||
  "mobile_app": "Mobile App",
 | 
			
		||||
  "mobile_app_download_onboarding_note": "You can access these options again from the Utilities page.",
 | 
			
		||||
  "model": "Model",
 | 
			
		||||
  "month": "Month",
 | 
			
		||||
  "monthly_title_text_date_format": "MMMM y",
 | 
			
		||||
| 
						 | 
				
			
			@ -1428,6 +1432,8 @@
 | 
			
		|||
  "notifications": "Notifications",
 | 
			
		||||
  "notifications_setting_description": "Manage notifications",
 | 
			
		||||
  "oauth": "OAuth",
 | 
			
		||||
  "obtainium_configurator": "Obtainium Configurator",
 | 
			
		||||
  "obtainium_configurator_instructions": "Please create an API key and select a variant to create your Obtainium configuration link.",
 | 
			
		||||
  "official_immich_resources": "Official Immich Resources",
 | 
			
		||||
  "offline": "Offline",
 | 
			
		||||
  "offset": "Offset",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,27 @@
 | 
			
		|||
<script lang="ts">
 | 
			
		||||
  import AppDownloadModal from '$lib/modals/AppDownloadModal.svelte';
 | 
			
		||||
  import ObtainiumConfigModal from '$lib/modals/ObtainiumConfigModal.svelte';
 | 
			
		||||
  import { Button, HStack, modalManager } from '@immich/ui';
 | 
			
		||||
  import { mdiCellphoneArrowDownVariant, mdiLinkEdit } from '@mdi/js';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<HStack wrap>
 | 
			
		||||
  <Button
 | 
			
		||||
    size="large"
 | 
			
		||||
    shape="semi-round"
 | 
			
		||||
    onclick={() => modalManager.show(ObtainiumConfigModal, {})}
 | 
			
		||||
    leadingIcon={mdiLinkEdit}
 | 
			
		||||
  >
 | 
			
		||||
    {$t('obtainium_configurator')}
 | 
			
		||||
  </Button>
 | 
			
		||||
  <Button
 | 
			
		||||
    size="large"
 | 
			
		||||
    shape="semi-round"
 | 
			
		||||
    onclick={() => modalManager.show(AppDownloadModal, {})}
 | 
			
		||||
    leadingIcon={mdiCellphoneArrowDownVariant}
 | 
			
		||||
  >
 | 
			
		||||
    {$t('app_download_links')}
 | 
			
		||||
  </Button>
 | 
			
		||||
</HStack>
 | 
			
		||||
<p>{$t('mobile_app_download_onboarding_note')}</p>
 | 
			
		||||
| 
						 | 
				
			
			@ -1,7 +1,15 @@
 | 
			
		|||
<script lang="ts">
 | 
			
		||||
  import { AppRoute } from '$lib/constants';
 | 
			
		||||
  import { Icon } from '@immich/ui';
 | 
			
		||||
  import { mdiContentDuplicate, mdiCrosshairsGps, mdiImageSizeSelectLarge } from '@mdi/js';
 | 
			
		||||
  import AppDownloadModal from '$lib/modals/AppDownloadModal.svelte';
 | 
			
		||||
  import ObtainiumConfigModal from '$lib/modals/ObtainiumConfigModal.svelte';
 | 
			
		||||
  import { Icon, modalManager } from '@immich/ui';
 | 
			
		||||
  import {
 | 
			
		||||
    mdiCellphoneArrowDownVariant,
 | 
			
		||||
    mdiContentDuplicate,
 | 
			
		||||
    mdiCrosshairsGps,
 | 
			
		||||
    mdiImageSizeSelectLarge,
 | 
			
		||||
    mdiLinkEdit,
 | 
			
		||||
  } from '@mdi/js';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
 | 
			
		||||
  const links = [
 | 
			
		||||
| 
						 | 
				
			
			@ -21,3 +29,27 @@
 | 
			
		|||
    </a>
 | 
			
		||||
  {/each}
 | 
			
		||||
</div>
 | 
			
		||||
<br />
 | 
			
		||||
<div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
 | 
			
		||||
  <p class="uppercase text-xs font-medium p-4">{$t('download')}</p>
 | 
			
		||||
  <button
 | 
			
		||||
    type="button"
 | 
			
		||||
    onclick={() => modalManager.show(ObtainiumConfigModal, {})}
 | 
			
		||||
    class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4"
 | 
			
		||||
  >
 | 
			
		||||
    <span>
 | 
			
		||||
      <Icon icon={mdiLinkEdit} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
 | 
			
		||||
    </span>
 | 
			
		||||
    {$t('obtainium_configurator')}
 | 
			
		||||
  </button>
 | 
			
		||||
  <button
 | 
			
		||||
    type="button"
 | 
			
		||||
    onclick={() => modalManager.show(AppDownloadModal, {})}
 | 
			
		||||
    class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4"
 | 
			
		||||
  >
 | 
			
		||||
    <span>
 | 
			
		||||
      <Icon icon={mdiCellphoneArrowDownVariant} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
 | 
			
		||||
    </span>
 | 
			
		||||
    {$t('app_download_links')}
 | 
			
		||||
  </button>
 | 
			
		||||
</div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										45
									
								
								web/src/lib/modals/AppDownloadModal.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								web/src/lib/modals/AppDownloadModal.svelte
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,45 @@
 | 
			
		|||
<script lang="ts">
 | 
			
		||||
  import { appStoreBadge, fdroidBadge, Modal, ModalBody, playStoreBadge } from '@immich/ui';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
  interface Props {
 | 
			
		||||
    onClose: () => void;
 | 
			
		||||
  }
 | 
			
		||||
  let { onClose }: Props = $props();
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<Modal title={$t('app_download_links')} size="large" {onClose}>
 | 
			
		||||
  <ModalBody>
 | 
			
		||||
    <div class="flex flex-col sm:grid sm:grid-cols-2 gap-5 text-immich-primary dark:text-immich-dark-primary">
 | 
			
		||||
      <div>
 | 
			
		||||
        <label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="fdroid-link">
 | 
			
		||||
          F-Droid
 | 
			
		||||
        </label>
 | 
			
		||||
        <a href="https://f-droid.org/packages/app.alextran.immich/" target="_blank" id="fdroid-link">
 | 
			
		||||
          <img class="pt-2 pr-10" alt="Get it on F-Droid" src={fdroidBadge} />
 | 
			
		||||
        </a>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div>
 | 
			
		||||
        <label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="play-store-link">
 | 
			
		||||
          Google Play
 | 
			
		||||
        </label>
 | 
			
		||||
        <a
 | 
			
		||||
          href="https://play.google.com/store/apps/details?id=app.alextran.immich"
 | 
			
		||||
          target="_blank"
 | 
			
		||||
          id="play-store-link"
 | 
			
		||||
        >
 | 
			
		||||
          <img alt="Get it on Google Play" src={playStoreBadge} />
 | 
			
		||||
        </a>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div>
 | 
			
		||||
        <label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="app-store-link">
 | 
			
		||||
          App Store
 | 
			
		||||
        </label>
 | 
			
		||||
        <a href="https://apps.apple.com/us/app/immich/id1613945652" target="_blank" id="app-store-link">
 | 
			
		||||
          <img class="pt-2 pr-5" alt="Download on the App Store" src={appStoreBadge} width="90%" />
 | 
			
		||||
        </a>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </ModalBody>
 | 
			
		||||
</Modal>
 | 
			
		||||
							
								
								
									
										94
									
								
								web/src/lib/modals/ObtainiumConfigModal.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								web/src/lib/modals/ObtainiumConfigModal.svelte
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,94 @@
 | 
			
		|||
<script lang="ts">
 | 
			
		||||
  import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
 | 
			
		||||
  import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
 | 
			
		||||
  import { SettingInputFieldType } from '$lib/constants';
 | 
			
		||||
  import { handleError } from '$lib/utils/handle-error';
 | 
			
		||||
  import { createApiKey, Permission } from '@immich/sdk';
 | 
			
		||||
  import { Button, Modal, ModalBody, obtainiumBadge } from '@immich/ui';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
  let inputUrl = $state(location.origin);
 | 
			
		||||
  let inputApiKey = $state('');
 | 
			
		||||
  let archVariant = $state('');
 | 
			
		||||
  let obtainiumLink = $derived(
 | 
			
		||||
    `https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.alextran.immich%22%2C%22url%22%3A%22${inputUrl}%2Fapi%2Fserver%2Fapk-links%22%2C%22author%22%3A%22Immich%22%2C%22name%22%3A%22Immich%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%2C%7B%5C%22requestHeader%5C%22%3A%5C%22x-api-key%3A%20${inputApiKey}%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22APKLinkHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%2Fv(%5C%5C%5C%5Cd%2B).(%5C%5C%5C%5Cd%2B).(%5C%5C%5C%5Cd%2B)%2F%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%241.%242.%243%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22app-${archVariant}.apk%24%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D`,
 | 
			
		||||
  );
 | 
			
		||||
  const handleCreate = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      const { secret } = await createApiKey({
 | 
			
		||||
        apiKeyCreateDto: {
 | 
			
		||||
          name: 'Obtainium',
 | 
			
		||||
          permissions: [Permission.ServerApkLinks],
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
      inputApiKey = secret;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, $t('errors.unable_to_create_api_key'));
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  interface Props {
 | 
			
		||||
    onClose: () => void;
 | 
			
		||||
  }
 | 
			
		||||
  let { onClose }: Props = $props();
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<Modal title={$t('obtainium_configurator')} size="large" {onClose}>
 | 
			
		||||
  <ModalBody>
 | 
			
		||||
    <div class="flex flex-col sm:grid sm:grid-cols-2 gap-5 text-immich-primary dark:text-immich-dark-primary">
 | 
			
		||||
      <div>
 | 
			
		||||
        <label
 | 
			
		||||
          class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm"
 | 
			
		||||
          for="obtainium-configurator"
 | 
			
		||||
        >
 | 
			
		||||
          Obtainium
 | 
			
		||||
        </label>
 | 
			
		||||
        <div id="obtainium-configurator">
 | 
			
		||||
          <form>
 | 
			
		||||
            <div class="mt-2">
 | 
			
		||||
              <SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('url')} bind:value={inputUrl} />
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="mt-2">
 | 
			
		||||
              <SettingInputField
 | 
			
		||||
                inputType={SettingInputFieldType.TEXT}
 | 
			
		||||
                label={$t('api_key')}
 | 
			
		||||
                bind:value={inputApiKey}
 | 
			
		||||
              />
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="">
 | 
			
		||||
              <Button shape="round" size="small" onclick={() => handleCreate()}>{$t('new_api_key')}</Button>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="mt-2">
 | 
			
		||||
              <SettingSelect
 | 
			
		||||
                label={$t('app_architecture_variant')}
 | 
			
		||||
                bind:value={archVariant}
 | 
			
		||||
                options={[
 | 
			
		||||
                  { value: 'arm64-v8a-release', text: 'arm64-v8a' },
 | 
			
		||||
                  { value: 'armeabi-v7a-release', text: 'armeabi-v7a' },
 | 
			
		||||
                  { value: 'release', text: 'universal' },
 | 
			
		||||
                  { value: 'x86_64-release', text: 'x86_64' },
 | 
			
		||||
                ]}
 | 
			
		||||
              />
 | 
			
		||||
            </div>
 | 
			
		||||
          </form>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div class="content-center">
 | 
			
		||||
        {#if inputUrl && inputApiKey && archVariant}
 | 
			
		||||
          <a
 | 
			
		||||
            href={obtainiumLink}
 | 
			
		||||
            class="underline text-sm immich-form-label"
 | 
			
		||||
            target="_blank"
 | 
			
		||||
            rel="noreferrer"
 | 
			
		||||
            id="obtainium-link"
 | 
			
		||||
          >
 | 
			
		||||
            <img class="pt-2 pr-5" alt="Get it on Obtainium" src={obtainiumBadge} />
 | 
			
		||||
          </a>
 | 
			
		||||
        {:else}
 | 
			
		||||
          <p class="immich-form-label pb-2 text-sm" id="obtainium-link">
 | 
			
		||||
            {$t('obtainium_configurator_instructions')}
 | 
			
		||||
          </p>
 | 
			
		||||
        {/if}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </ModalBody>
 | 
			
		||||
</Modal>
 | 
			
		||||
| 
						 | 
				
			
			@ -5,6 +5,7 @@
 | 
			
		|||
  import OnboardingCard from '$lib/components/onboarding-page/onboarding-card.svelte';
 | 
			
		||||
  import OnboardingHello from '$lib/components/onboarding-page/onboarding-hello.svelte';
 | 
			
		||||
  import OnboardingLocale from '$lib/components/onboarding-page/onboarding-language.svelte';
 | 
			
		||||
  import OnboardingMobileApp from '$lib/components/onboarding-page/onboarding-mobile-app.svelte';
 | 
			
		||||
  import OnboardingServerPrivacy from '$lib/components/onboarding-page/onboarding-server-privacy.svelte';
 | 
			
		||||
  import OnboardingStorageTemplate from '$lib/components/onboarding-page/onboarding-storage-template.svelte';
 | 
			
		||||
  import OnboardingTheme from '$lib/components/onboarding-page/onboarding-theme.svelte';
 | 
			
		||||
| 
						 | 
				
			
			@ -14,7 +15,14 @@
 | 
			
		|||
  import { retrieveServerConfig, retrieveSystemConfig, serverConfig } from '$lib/stores/server-config.store';
 | 
			
		||||
  import { user } from '$lib/stores/user.store';
 | 
			
		||||
  import { setUserOnboarding, updateAdminOnboarding } from '@immich/sdk';
 | 
			
		||||
  import { mdiCloudCheckOutline, mdiHarddisk, mdiIncognito, mdiThemeLightDark, mdiTranslate } from '@mdi/js';
 | 
			
		||||
  import {
 | 
			
		||||
    mdiCellphoneArrowDownVariant,
 | 
			
		||||
    mdiCloudCheckOutline,
 | 
			
		||||
    mdiHarddisk,
 | 
			
		||||
    mdiIncognito,
 | 
			
		||||
    mdiThemeLightDark,
 | 
			
		||||
    mdiTranslate,
 | 
			
		||||
  } from '@mdi/js';
 | 
			
		||||
  import { onMount } from 'svelte';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -26,6 +34,7 @@
 | 
			
		|||
      | typeof OnboardingStorageTemplate
 | 
			
		||||
      | typeof OnboardingServerPrivacy
 | 
			
		||||
      | typeof OnboardingUserPrivacy
 | 
			
		||||
      | typeof OnboardingMobileApp
 | 
			
		||||
      | typeof OnboardingLocale;
 | 
			
		||||
    role: OnboardingRole;
 | 
			
		||||
    title?: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -76,6 +85,13 @@
 | 
			
		|||
      title: $t('admin.backup_onboarding_title'),
 | 
			
		||||
      icon: mdiCloudCheckOutline,
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: 'mobile_app',
 | 
			
		||||
      component: OnboardingMobileApp,
 | 
			
		||||
      role: OnboardingRole.USER,
 | 
			
		||||
      title: $t('mobile_app'),
 | 
			
		||||
      icon: mdiCellphoneArrowDownVariant, // or you can use mdiCellphone
 | 
			
		||||
    },
 | 
			
		||||
  ]);
 | 
			
		||||
 | 
			
		||||
  let index = $state(0);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue