diff --git a/.changeset/add-mobile-app-icon-selection.md b/.changeset/add-mobile-app-icon-selection.md new file mode 100644 index 0000000000..56089b1db6 --- /dev/null +++ b/.changeset/add-mobile-app-icon-selection.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add selectable app icons on Android and iOS. diff --git a/package.json b/package.json index ac8a069494..44e8aada8a 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "postinstall": "node scripts/install-knope.js", "setup:knope": "node scripts/install-knope.js", "setup:tauri-icons": "node scripts/icons/symlink.js", + "generate:app-icons:android": "node node_modules/tauri-plugin-app-icon-api/scripts/generate-android.mjs --config src-tauri/app-icons.json && pnpm setup:tauri-icons --write --force", + "generate:app-icons:ios": "node node_modules/tauri-plugin-app-icon-api/scripts/generate-ios.mjs --config src-tauri/app-icons.json", "lint:normalize-imports": "node scripts/normalize-imports.js" }, "dependencies": { @@ -122,6 +124,7 @@ "workbox-precaching": "^7.4.1" }, "devDependencies": { + "tauri-plugin-app-icon-api": "github:SableClient/tauri-plugin-app-icon#9248a3370dca5c9b1640b20806002fe59d25fa57", "@cloudflare/vite-plugin": "^1.42.4", "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@faker-js/faker": "^10.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a1617d982..eea31c8f31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -356,6 +356,9 @@ importers: oxlint-tsgolint: specifier: ^0.24.0 version: 0.24.0 + tauri-plugin-app-icon-api: + specifier: github:SableClient/tauri-plugin-app-icon#9248a3370dca5c9b1640b20806002fe59d25fa57 + version: https://codeload.github.com/SableClient/tauri-plugin-app-icon/tar.gz/9248a3370dca5c9b1640b20806002fe59d25fa57 testcontainers: specifier: ^12.0.4 version: 12.0.4 @@ -5333,6 +5336,10 @@ packages: tauri-plugin-android-fs-api@28.4.0: resolution: {integrity: sha512-YOo1P+cRjuaoPbTNZprMhr56ruKO4hIDq7kJOum91NqkN+VWY9F+UPAdjZokhzJSsbkeY0nsb1VDLXb2/h3f3w==} + tauri-plugin-app-icon-api@https://codeload.github.com/SableClient/tauri-plugin-app-icon/tar.gz/9248a3370dca5c9b1640b20806002fe59d25fa57: + resolution: {tarball: https://codeload.github.com/SableClient/tauri-plugin-app-icon/tar.gz/9248a3370dca5c9b1640b20806002fe59d25fa57} + version: 0.1.0 + teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} @@ -10754,6 +10761,10 @@ snapshots: '@tauri-apps/api': 2.11.1 create-web-stream: 1.1.3 + tauri-plugin-app-icon-api@https://codeload.github.com/SableClient/tauri-plugin-app-icon/tar.gz/9248a3370dca5c9b1640b20806002fe59d25fa57: + dependencies: + '@tauri-apps/api': 2.11.1 + teex@1.0.1: dependencies: streamx: 2.28.0 diff --git a/scripts/icons/symlink.js b/scripts/icons/symlink.js index 1f0c4ae8f2..1bb6d95d29 100755 --- a/scripts/icons/symlink.js +++ b/scripts/icons/symlink.js @@ -8,6 +8,12 @@ import path from 'node:path'; import process from 'node:process'; import { createTextHelpers } from '../utils/console-style.js'; +const ROOT = process.cwd(); +const appIconsPath = path.join(ROOT, 'src-tauri', 'app-icons.json'); +const appIcons = fs.existsSync(appIconsPath) + ? JSON.parse(fs.readFileSync(appIconsPath, 'utf8')).icons + : []; + const ANDROID_ICONS = [ 'mipmap-hdpi', 'mipmap-mdpi', @@ -22,7 +28,19 @@ const ANDROID_ICONS = [ `${dir}/ic_notification.png`, ]) .concat(['mipmap-anydpi-v26/ic_launcher.xml', 'values/ic_launcher_background.xml']) - .concat(['drawable/ic_notification.xml', 'drawable/notification_icon.xml']); + .concat(['drawable/ic_notification.xml', 'drawable/notification_icon.xml']) + .concat( + appIcons.flatMap(({ id }) => [ + ...['mipmap-hdpi', 'mipmap-mdpi', 'mipmap-xhdpi', 'mipmap-xxhdpi', 'mipmap-xxxhdpi'].flatMap( + (dir) => [ + `${dir}/ic_launcher_${id}.png`, + `${dir}/ic_launcher_${id}_round.png`, + `${dir}/ic_launcher_${id}_foreground.png`, + ] + ), + `mipmap-anydpi-v26/ic_launcher_${id}.xml`, + ]) + ); const IOS_ICONS = [ 'AppIcon-20x20@1x.png', @@ -133,7 +151,6 @@ function processGroup(label, srcDir, destDir, files, write, force, helpers) { } function main() { - const ROOT = process.cwd(); const { write, force } = parseArgs(process.argv.slice(2)); const helpers = createTextHelpers(); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5d99a6a8c7..7bfd9f29cc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5473,6 +5473,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-android-fs", + "tauri-plugin-app-icon", "tauri-plugin-clipboard-manager", "tauri-plugin-deep-link", "tauri-plugin-devtools", @@ -6643,6 +6644,17 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "tauri-plugin-app-icon" +version = "0.1.0" +source = "git+https://github.com/SableClient/tauri-plugin-app-icon?rev=9248a3370dca5c9b1640b20806002fe59d25fa57#9248a3370dca5c9b1640b20806002fe59d25fa57" +dependencies = [ + "serde", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", +] + [[package]] name = "tauri-plugin-clipboard-manager" version = "2.3.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7ab48a7ae0..7a258bff7d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -124,6 +124,7 @@ tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugi ] } tauri-plugin-edge-to-edge = { git = "https://github.com/SableClient/tauri-plugin-edge-to-edge.git", rev = "33c6116c27be28c06df5a9d02231ecc5fdeb93c5" } tauri-plugin-sharekit = { git = "https://github.com/Choochmeque/tauri-plugin-sharekit", rev = "9f2b4c5d8a4f0ab910d900ba234ed8bae0ab854b" } +tauri-plugin-app-icon = { git = "https://github.com/SableClient/tauri-plugin-app-icon", rev = "9248a3370dca5c9b1640b20806002fe59d25fa57" } [target.'cfg(target_os = "ios")'.dependencies] objc2 = "0.6" diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist index 99b9ecb70b..8897bb3666 100644 --- a/src-tauri/Info.plist +++ b/src-tauri/Info.plist @@ -10,5 +10,20 @@ Sable needs camera access for video calls. NSMicrophoneUsageDescription Sable needs microphone access for voice and video calls. + + CFBundleIcons + + CFBundleAlternateIcons + + propeler + + CFBundleIconFiles + + AppIconPropeler + + + + + diff --git a/src-tauri/app-icons.json b/src-tauri/app-icons.json new file mode 100644 index 0000000000..161d69a296 --- /dev/null +++ b/src-tauri/app-icons.json @@ -0,0 +1,17 @@ +{ + "android": { + "mainActivity": ".MainActivity", + "resourcesDirectory": "icons/android", + "manifest": "gen/android/app/src/main/AndroidManifest.xml" + }, + "ios": { + "assetsDirectory": "icons/ios/AppIcons.xcassets", + "infoPlist": "Info.plist" + }, + "icons": [ + { + "id": "propeler", + "source": "icons/app-icons/propeler.png" + } + ] +} diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/mobile.json index e5500a57d6..1b75607f31 100644 --- a/src-tauri/capabilities/mobile.json +++ b/src-tauri/capabilities/mobile.json @@ -5,6 +5,7 @@ "windows": ["main"], "permissions": [ "core:window:allow-set-badge-count", + "app-icon:default", "deep-link:default", "edge-to-edge:default", "notifications:default", diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 7ada59c8d5..60bd071d6d 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -48,13 +48,6 @@ android:label="@string/main_activity_title" android:name="moe.sable.client.MainActivity" android:exported="true"> - - - - - - - @@ -120,6 +113,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_propeler.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_propeler.png new file mode 100644 index 0000000000..32401aa561 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_propeler.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_propeler_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_propeler_foreground.png new file mode 100644 index 0000000000..b130afcd93 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_propeler_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_propeler_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_propeler_round.png new file mode 100644 index 0000000000..8dd8b24de1 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_propeler_round.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_propeler.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_propeler.png new file mode 100644 index 0000000000..005a773e90 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_propeler.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_propeler_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_propeler_foreground.png new file mode 100644 index 0000000000..a6c1487591 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_propeler_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_propeler_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_propeler_round.png new file mode 100644 index 0000000000..c7a357b8b9 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_propeler_round.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_propeler.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_propeler.png new file mode 100644 index 0000000000..fa3f387906 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_propeler.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_propeler_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_propeler_foreground.png new file mode 100644 index 0000000000..88dca648a6 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_propeler_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_propeler_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_propeler_round.png new file mode 100644 index 0000000000..cc010516d7 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_propeler_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_propeler.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_propeler.png new file mode 100644 index 0000000000..6bda773c9e Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_propeler.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_propeler_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_propeler_foreground.png new file mode 100644 index 0000000000..212c4f7e86 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_propeler_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_propeler_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_propeler_round.png new file mode 100644 index 0000000000..81dade6c5f Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_propeler_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_propeler.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_propeler.png new file mode 100644 index 0000000000..c650186171 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_propeler.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_propeler_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_propeler_foreground.png new file mode 100644 index 0000000000..da99067e52 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_propeler_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_propeler_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_propeler_round.png new file mode 100644 index 0000000000..333e1b7242 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_propeler_round.png differ diff --git a/src-tauri/icons/app-icons/propeler.png b/src-tauri/icons/app-icons/propeler.png new file mode 100644 index 0000000000..99cee414e5 Binary files /dev/null and b/src-tauri/icons/app-icons/propeler.png differ diff --git a/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-20x20@1x.png new file mode 100644 index 0000000000..b5bd8562e4 Binary files /dev/null and b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000000..173fa98da3 Binary files /dev/null and b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-20x20@2x.png new file mode 100644 index 0000000000..173fa98da3 Binary files /dev/null and b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-20x20@3x.png new file mode 100644 index 0000000000..faf9c288b9 Binary files /dev/null and b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-29x29@1x.png new file mode 100644 index 0000000000..1537cd0a84 Binary files /dev/null and b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000000..0281e044e1 Binary files /dev/null and b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-29x29@2x.png new file mode 100644 index 0000000000..0281e044e1 Binary files /dev/null and b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-29x29@3x.png new file mode 100644 index 0000000000..04d429a44b Binary files /dev/null and b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-40x40@1x.png new file mode 100644 index 0000000000..173fa98da3 Binary files /dev/null and b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000000..c46b66b0c8 Binary files /dev/null and b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-40x40@2x.png new file mode 100644 index 0000000000..c46b66b0c8 Binary files /dev/null and b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-40x40@3x.png new file mode 100644 index 0000000000..c3e6c67192 Binary files /dev/null and b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-512@2x.png new file mode 100644 index 0000000000..afd3dccd41 Binary files /dev/null and b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-60x60@2x.png new file mode 100644 index 0000000000..c3e6c67192 Binary files /dev/null and b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-60x60@3x.png new file mode 100644 index 0000000000..356b7131a3 Binary files /dev/null and b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-76x76@1x.png new file mode 100644 index 0000000000..0b797bc16a Binary files /dev/null and b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-76x76@2x.png new file mode 100644 index 0000000000..6a76ce6bb5 Binary files /dev/null and b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000000..0dae0d7693 Binary files /dev/null and b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/Contents.json b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/Contents.json new file mode 100644 index 0000000000..dd3b8bcc55 --- /dev/null +++ b/src-tauri/icons/ios/AppIcons.xcassets/AppIconPropeler.appiconset/Contents.json @@ -0,0 +1,116 @@ +{ + "images": [ + { + "size": "20x20", + "idiom": "iphone", + "filename": "AppIcon-20x20@2x.png", + "scale": "2x" + }, + { + "size": "20x20", + "idiom": "iphone", + "filename": "AppIcon-20x20@3x.png", + "scale": "3x" + }, + { + "size": "29x29", + "idiom": "iphone", + "filename": "AppIcon-29x29@2x-1.png", + "scale": "2x" + }, + { + "size": "29x29", + "idiom": "iphone", + "filename": "AppIcon-29x29@3x.png", + "scale": "3x" + }, + { + "size": "40x40", + "idiom": "iphone", + "filename": "AppIcon-40x40@2x.png", + "scale": "2x" + }, + { + "size": "40x40", + "idiom": "iphone", + "filename": "AppIcon-40x40@3x.png", + "scale": "3x" + }, + { + "size": "60x60", + "idiom": "iphone", + "filename": "AppIcon-60x60@2x.png", + "scale": "2x" + }, + { + "size": "60x60", + "idiom": "iphone", + "filename": "AppIcon-60x60@3x.png", + "scale": "3x" + }, + { + "size": "20x20", + "idiom": "ipad", + "filename": "AppIcon-20x20@1x.png", + "scale": "1x" + }, + { + "size": "20x20", + "idiom": "ipad", + "filename": "AppIcon-20x20@2x-1.png", + "scale": "2x" + }, + { + "size": "29x29", + "idiom": "ipad", + "filename": "AppIcon-29x29@1x.png", + "scale": "1x" + }, + { + "size": "29x29", + "idiom": "ipad", + "filename": "AppIcon-29x29@2x.png", + "scale": "2x" + }, + { + "size": "40x40", + "idiom": "ipad", + "filename": "AppIcon-40x40@1x.png", + "scale": "1x" + }, + { + "size": "40x40", + "idiom": "ipad", + "filename": "AppIcon-40x40@2x-1.png", + "scale": "2x" + }, + { + "size": "76x76", + "idiom": "ipad", + "filename": "AppIcon-76x76@1x.png", + "scale": "1x" + }, + { + "size": "76x76", + "idiom": "ipad", + "filename": "AppIcon-76x76@2x.png", + "scale": "2x" + }, + { + "size": "83.5x83.5", + "idiom": "ipad", + "filename": "AppIcon-83.5x83.5@2x.png", + "scale": "2x" + }, + { + "size": "1024x1024", + "idiom": "ios-marketing", + "filename": "AppIcon-512@2x.png", + "scale": "1x" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} diff --git a/src-tauri/icons/ios/AppIcons.xcassets/Contents.json b/src-tauri/icons/ios/AppIcons.xcassets/Contents.json new file mode 100644 index 0000000000..ac502da0a1 --- /dev/null +++ b/src-tauri/icons/ios/AppIcons.xcassets/Contents.json @@ -0,0 +1 @@ +{ "info": { "author": "xcode", "version": 1 } } diff --git a/src-tauri/ios-project.yml b/src-tauri/ios-project.yml index 298ff0cb6d..e19c1d821e 100644 --- a/src-tauri/ios-project.yml +++ b/src-tauri/ios-project.yml @@ -69,6 +69,7 @@ targets: sources: - path: Sources - path: Assets.xcassets + - path: ../../icons/ios/AppIcons.xcassets - path: Externals buildPhase: none - path: {{app.name}}_iOS diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 95bf8c5479..69e8b33ce3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -361,6 +361,7 @@ pub fn run() { #[cfg(mobile)] let builder = builder + .plugin(tauri_plugin_app_icon::init()) .plugin(tauri_plugin_edge_to_edge::init()) .plugin(tauri_plugin_sharekit::init()); diff --git a/src-tauri/tauri.android.conf.json b/src-tauri/tauri.android.conf.json new file mode 100644 index 0000000000..690a377366 --- /dev/null +++ b/src-tauri/tauri.android.conf.json @@ -0,0 +1,6 @@ +{ + "build": { + "beforeDevCommand": "pnpm generate:app-icons:android && pnpm dev", + "beforeBuildCommand": "pnpm generate:app-icons:android && pnpm build" + } +} diff --git a/src-tauri/tauri.ios.conf.json b/src-tauri/tauri.ios.conf.json new file mode 100644 index 0000000000..2b2199eb2a --- /dev/null +++ b/src-tauri/tauri.ios.conf.json @@ -0,0 +1,6 @@ +{ + "build": { + "beforeDevCommand": "pnpm generate:app-icons:ios && pnpm dev", + "beforeBuildCommand": "pnpm generate:app-icons:ios && pnpm build" + } +} diff --git a/src/app/components/app-shell/AppShell.tsx b/src/app/components/app-shell/AppShell.tsx index 4c29e0fab5..639959767d 100644 --- a/src/app/components/app-shell/AppShell.tsx +++ b/src/app/components/app-shell/AppShell.tsx @@ -20,6 +20,7 @@ import { ScreenSizeProvider } from '$hooks/useScreenSize'; import { isReactQueryDevtoolsEnabled } from '$pages/reactQueryDevtoolsGate'; import { useDesktopSetting } from '$state/hooks/desktopSettings'; import { getCustomTitlebarKind } from '$utils/tauriTitlebar'; +import { AppIconRuntimeFeature } from '$features/settings/cosmetics/AppIconSettings'; import { SystemBarShell } from './SystemBarShell'; const ReactQueryDevtools = lazy(async () => { @@ -82,6 +83,7 @@ function AppShellFrame({ children, portalContainer, onPortalContainerChange }: A <> +
= { align?: MenuAlign; offset?: number; scrollable?: boolean; + optionStyle?: CSSProperties; renderTrigger?: (args: SettingMenuRenderTriggerArgs) => ReactNode; renderOption?: (args: SettingMenuRenderOptionArgs) => ReactNode; }; @@ -61,6 +63,7 @@ export function SettingMenuSelector({ align = 'End', offset = 5, scrollable = false, + optionStyle, renderTrigger, renderOption, }: SettingMenuSelectorProps) { @@ -100,6 +103,7 @@ export function SettingMenuSelector({ aria-selected={selected} disabled={option.disabled || isDisabled} onClick={select} + style={optionStyle} > {renderOption({ option, selected, select })} @@ -116,6 +120,7 @@ export function SettingMenuSelector({ disabled={option.disabled || isDisabled} onClick={select} before={option.icon} + style={optionStyle} > diff --git a/src/app/features/settings/cosmetics/AppIconSettings.test.tsx b/src/app/features/settings/cosmetics/AppIconSettings.test.tsx new file mode 100644 index 0000000000..12d80f10b6 --- /dev/null +++ b/src/app/features/settings/cosmetics/AppIconSettings.test.tsx @@ -0,0 +1,138 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AppIconRuntimeFeature, AppIconSettings } from './AppIconSettings'; + +const { invoke, isAndroidTauri, isMobileOrTablet, isMobileTauri, setAppIconId, settings } = + vi.hoisted(() => ({ + invoke: vi.fn<(command: string, args?: unknown) => Promise>(), + isAndroidTauri: vi.fn<() => boolean>(), + isMobileOrTablet: vi.fn<() => boolean>(), + isMobileTauri: vi.fn<() => boolean>(), + setAppIconId: vi.fn<(value: string | undefined) => void>(), + settings: { appIconId: undefined as string | undefined }, + })); + +vi.mock('@tauri-apps/api/core', () => ({ invoke })); +vi.mock('$utils/platform', () => ({ isAndroidTauri, isMobileOrTablet, isMobileTauri })); +vi.mock('$state/hooks/settings', () => ({ useSetting: () => [settings.appIconId, setAppIconId] })); +vi.mock('$components/sequence-card', () => ({ + SequenceCard: ({ children }: { children: React.ReactNode }) =>
{children}
, + SequenceCardStyle: 'card', +})); +vi.mock('$components/setting-tile', () => ({ + SettingTile: ({ title, after }: { title: string; after: React.ReactNode }) => ( +
+ {title} + {after} +
+ ), +})); +vi.mock('$components/setting-menu-selector', () => ({ + SettingMenuSelector: ({ + options, + onSelect, + }: { + options: { value: string; label: string; icon?: React.ReactNode }[]; + onSelect: (value: string) => void; + }) => ( + <> + {options.map((option) => ( + + ))} + + ), +})); + +describe('AppIconSettings', () => { + beforeEach(() => { + invoke.mockReset(); + isAndroidTauri.mockReturnValue(false); + isMobileOrTablet.mockReturnValue(false); + isMobileTauri.mockReset(); + setAppIconId.mockReset(); + settings.appIconId = undefined; + }); + + it('is hidden outside mobile Tauri', () => { + isMobileTauri.mockReturnValue(false); + + render(); + + expect(screen.queryByText('App Icon')).not.toBeInTheDocument(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('is hidden when the native bundle has no alternate icons', async () => { + isMobileTauri.mockReturnValue(true); + invoke.mockResolvedValue([]); + + render(); + + await waitFor(() => expect(invoke).toHaveBeenCalled()); + expect(screen.queryByText('App Icon')).not.toBeInTheDocument(); + }); + + it('persists an icon only after native selection succeeds', async () => { + isMobileTauri.mockReturnValue(true); + invoke.mockResolvedValueOnce(['propeler']).mockResolvedValueOnce(undefined); + + render(); + + await screen.findByText('App Icon'); + expect(screen.getByTestId('app-icon-preview-primary')).toBeInTheDocument(); + expect(screen.getByTestId('app-icon-preview-propeler')).toBeInTheDocument(); + expect(screen.getByTestId('app-icon-preview-primary')).toHaveStyle({ borderRadius: '22.5%' }); + fireEvent.click(screen.getByRole('button', { name: 'Propeler' })); + + await waitFor(() => { + expect(invoke).toHaveBeenLastCalledWith('plugin:app-icon|set_icon', { + request: { icon: 'propeler' }, + }); + }); + expect(setAppIconId).toHaveBeenCalledWith('propeler'); + }); + + it('renders circular previews on Android', async () => { + isMobileTauri.mockReturnValue(true); + isAndroidTauri.mockReturnValue(true); + invoke.mockResolvedValue(['propeler']); + + render(); + + expect(await screen.findByTestId('app-icon-preview-propeler')).toHaveStyle({ + borderRadius: '50%', + }); + }); + + it('restores the persisted icon when an update resets the native selection', async () => { + isMobileTauri.mockReturnValue(true); + settings.appIconId = 'propeler'; + invoke + .mockResolvedValueOnce(['propeler']) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(undefined); + + render(); + + await waitFor(() => { + expect(invoke).toHaveBeenLastCalledWith('plugin:app-icon|set_icon', { + request: { icon: 'propeler' }, + }); + }); + }); + + it('leaves the native selection alone when it already matches', async () => { + isMobileTauri.mockReturnValue(true); + settings.appIconId = 'propeler'; + invoke.mockResolvedValueOnce(['propeler']).mockResolvedValueOnce('propeler'); + + render(); + + await waitFor(() => expect(invoke).toHaveBeenCalledTimes(2)); + expect(invoke).not.toHaveBeenCalledWith('plugin:app-icon|set_icon', expect.anything()); + }); +}); diff --git a/src/app/features/settings/cosmetics/AppIconSettings.tsx b/src/app/features/settings/cosmetics/AppIconSettings.tsx new file mode 100644 index 0000000000..5afa180ff0 --- /dev/null +++ b/src/app/features/settings/cosmetics/AppIconSettings.tsx @@ -0,0 +1,129 @@ +import { invoke } from '@tauri-apps/api/core'; +import { useEffect, useState } from 'react'; + +import { SettingMenuSelector } from '$components/setting-menu-selector'; +import { SequenceCard, SequenceCardStyle } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { isAndroidTauri, isMobileTauri } from '$utils/platform'; +import defaultIcon from './app-icons/default.png'; +import propelerIcon from './app-icons/propeler.png'; + +const PRIMARY_ICON = 'primary'; +const APP_ICON_PREVIEWS: Record = { + [PRIMARY_ICON]: defaultIcon, + propeler: propelerIcon, +}; + +function AppIconPreview({ icon }: { icon: string }) { + const src = APP_ICON_PREVIEWS[icon]; + if (!src) return null; + + return ( + + ); +} + +export function AppIconRuntimeFeature() { + const [appIconId] = useSetting(settingsAtom, 'appIconId'); + + useEffect(() => { + if (!isMobileTauri()) return; + + let cancelled = false; + Promise.all([ + invoke('plugin:app-icon|get_available_icons'), + invoke('plugin:app-icon|get_current_icon'), + ]) + .then(async ([icons, current]) => { + const icon = icons.includes(appIconId ?? '') ? appIconId! : null; + if (!cancelled && current !== icon) { + await invoke('plugin:app-icon|set_icon', { request: { icon } }); + } + }) + .catch(() => {}); + + return () => { + cancelled = true; + }; + }, [appIconId]); + + return null; +} + +export function AppIconSettings() { + const [appIconId, setAppIconId] = useSetting(settingsAtom, 'appIconId'); + const [icons, setIcons] = useState(); + const [changing, setChanging] = useState(false); + + useEffect(() => { + if (!isMobileTauri()) return; + + let cancelled = false; + invoke('plugin:app-icon|get_available_icons') + .then((availableIcons) => { + if (!cancelled) setIcons(availableIcons); + }) + .catch(() => { + if (!cancelled) setIcons([]); + }); + + return () => { + cancelled = true; + }; + }, []); + + if (!icons?.length) return null; + + const options = [ + { value: PRIMARY_ICON, label: 'Default', icon: }, + ...icons.map((icon) => ({ + value: icon, + label: icon === 'propeler' ? 'Propeler' : icon, + icon: , + })), + ]; + const selectedIcon = icons.includes(appIconId ?? '') ? appIconId! : PRIMARY_ICON; + + const selectIcon = async (icon: string) => { + if (changing || icon === selectedIcon) return; + + setChanging(true); + try { + await invoke('plugin:app-icon|set_icon', { + request: { icon: icon === PRIMARY_ICON ? null : icon }, + }); + setAppIconId(icon === PRIMARY_ICON ? undefined : icon); + } finally { + setChanging(false); + } + }; + + return ( + + + } + /> + + ); +} diff --git a/src/app/features/settings/cosmetics/Cosmetics.tsx b/src/app/features/settings/cosmetics/Cosmetics.tsx index 9e6a69b904..d478333c00 100644 --- a/src/app/features/settings/cosmetics/Cosmetics.tsx +++ b/src/app/features/settings/cosmetics/Cosmetics.tsx @@ -14,6 +14,7 @@ import { SettingTile, SettingToggle } from '$components/setting-tile'; import { stopPropagation } from '$utils/keyboard'; import { Appearance } from './Themes'; import { LanguageSpecificPronouns } from './LanguageSpecificPronouns'; +import { AppIconSettings } from './AppIconSettings'; function PronounPillMaxCountInput({ disabled }: { disabled: boolean }) { const [maxCount, setMaxCount] = useSetting(settingsAtom, 'pronounPillMaxCount'); @@ -518,6 +519,7 @@ export function Cosmetics({ requestBack, requestClose }: CosmeticsProps) { {!themeBrowserOpen && ( <> + diff --git a/src/app/features/settings/cosmetics/app-icons/default.png b/src/app/features/settings/cosmetics/app-icons/default.png new file mode 100644 index 0000000000..32db07c0d1 Binary files /dev/null and b/src/app/features/settings/cosmetics/app-icons/default.png differ diff --git a/src/app/features/settings/cosmetics/app-icons/propeler.png b/src/app/features/settings/cosmetics/app-icons/propeler.png new file mode 100644 index 0000000000..99cee414e5 Binary files /dev/null and b/src/app/features/settings/cosmetics/app-icons/propeler.png differ diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts index aed1ab18ac..7db60f8e2a 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -115,6 +115,7 @@ export const settingsLinkFocusIdsBySection: Record