Skip to content

Commit 736d1d2

Browse files
committed
refactor(desktop): Save downloads to user's Downloads folder
This commit changes the download location for desktop platforms (Windows, macOS, Linux) from a temporary, app-specific directory to the standard user "Downloads" folder. Key changes: - Introduced a `userDownloadsDir()` function in `FileLocationsProvider` to abstract the platform-specific path to the user's Downloads directory. - Implemented `userDownloadsDir()` in `DesktopFileLocationsProvider` to correctly resolve the Downloads path for Windows, macOS, and Linux (including XDG support). - Updated `DesktopDownloader` to use the new `userDownloadsDir()` for saving downloaded files. - Added a Snackbar notification on desktop to inform the user that the installer has been saved to their Downloads folder, as auto-installation is not performed. - The `saveInstalledAppToDatabase` logic is now skipped for desktop platforms, as the app is not "installed" in the same way as on Android.
1 parent 69b4eda commit 736d1d2

File tree

9 files changed

+109
-21
lines changed

9 files changed

+109
-21
lines changed
95 Bytes
Binary file not shown.
96 Bytes
Binary file not shown.

composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/feature/details/data/AndroidFileLocationsProvider.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package zed.rainxch.githubstore.feature.details.data
22

33
import android.content.Context
4+
import android.os.Build
5+
import android.os.Environment
46
import java.io.File
57

68
class AndroidFileLocationsProvider(
@@ -13,6 +15,10 @@ class AndroidFileLocationsProvider(
1315
return dir.absolutePath
1416
}
1517

18+
override fun userDownloadsDir(): String {
19+
return "" // No-op
20+
}
21+
1622
override fun setExecutableIfNeeded(path: String) {
1723
// No-op on Android
1824
}

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/data/FileLocationsProvider.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ package zed.rainxch.githubstore.feature.details.data
22

33
interface FileLocationsProvider {
44
fun appDownloadsDir(): String
5+
fun userDownloadsDir(): String
56
fun setExecutableIfNeeded(path: String)
67
}

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsEvent.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ package zed.rainxch.githubstore.feature.details.presentation
33
sealed interface DetailsEvent {
44
data class OnOpenRepositoryInApp(val repositoryId: Int) : DetailsEvent
55
data class InstallTrackingFailed(val message: String) : DetailsEvent
6+
data class OnMessage(val message: String) : DetailsEvent
67
}

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsRoot.kt

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,15 @@ import androidx.compose.material3.IconButton
1919
import androidx.compose.material3.IconButtonDefaults
2020
import androidx.compose.material3.MaterialTheme
2121
import androidx.compose.material3.Scaffold
22+
import androidx.compose.material3.SnackbarHost
23+
import androidx.compose.material3.SnackbarHostState
2224
import androidx.compose.material3.TopAppBar
2325
import androidx.compose.material3.TopAppBarDefaults
2426
import androidx.compose.runtime.Composable
2527
import androidx.compose.runtime.CompositionLocalProvider
2628
import androidx.compose.runtime.getValue
29+
import androidx.compose.runtime.remember
30+
import androidx.compose.runtime.rememberCoroutineScope
2731
import androidx.compose.ui.Alignment
2832
import androidx.compose.ui.Modifier
2933
import androidx.compose.ui.graphics.Color
@@ -32,6 +36,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
3236
import io.github.fletchmckee.liquid.liquefiable
3337
import io.github.fletchmckee.liquid.liquid
3438
import io.github.fletchmckee.liquid.rememberLiquidState
39+
import kotlinx.coroutines.launch
3540
import org.jetbrains.compose.ui.tooling.preview.Preview
3641
import org.koin.compose.viewmodel.koinViewModel
3742
import zed.rainxch.githubstore.core.presentation.theme.GithubStoreTheme
@@ -53,6 +58,8 @@ fun DetailsRoot(
5358
viewModel: DetailsViewModel = koinViewModel()
5459
) {
5560
val state by viewModel.state.collectAsStateWithLifecycle()
61+
val snackbarHostState = remember { SnackbarHostState() }
62+
val coroutineScope = rememberCoroutineScope()
5663

5764
ObserveAsEvents(viewModel.events) { event ->
5865
when (event) {
@@ -63,11 +70,18 @@ fun DetailsRoot(
6370
is DetailsEvent.InstallTrackingFailed -> {
6471

6572
}
73+
74+
is DetailsEvent.OnMessage -> {
75+
coroutineScope.launch {
76+
snackbarHostState.showSnackbar(event.message)
77+
}
78+
}
6679
}
6780
}
6881

6982
DetailsScreen(
7083
state = state,
84+
snackbarHostState = snackbarHostState,
7185
onAction = { action ->
7286
when (action) {
7387
DetailsAction.OnNavigateBackClick -> {
@@ -91,6 +105,7 @@ fun DetailsRoot(
91105
fun DetailsScreen(
92106
state: DetailsState,
93107
onAction: (DetailsAction) -> Unit,
108+
snackbarHostState: SnackbarHostState,
94109
) {
95110
val liquidTopbarState = rememberLiquidState()
96111

@@ -148,7 +163,10 @@ fun DetailsScreen(
148163
)
149164
},
150165
containerColor = MaterialTheme.colorScheme.background,
151-
modifier = Modifier.liquefiable(liquidTopbarState)
166+
modifier = Modifier.liquefiable(liquidTopbarState),
167+
snackbarHost = {
168+
SnackbarHost(snackbarHostState)
169+
}
152170
) { innerPadding ->
153171

154172
if (state.isLoading) {
@@ -216,7 +234,8 @@ private fun Preview() {
216234
state = DetailsState(
217235
isLoading = false
218236
),
219-
onAction = {}
237+
onAction = {},
238+
snackbarHostState = SnackbarHostState()
220239
)
221240
}
222241
}

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsViewModel.kt

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -519,20 +519,18 @@ class DetailsViewModel(
519519
)
520520

521521
installer.ensurePermissionsOrThrow(
522-
assetName.substringAfterLast('.', "").lowercase()
522+
extOrMime = assetName.substringAfterLast('.', "").lowercase()
523523
)
524524

525525
_state.value = _state.value.copy(downloadStage = DownloadStage.DOWNLOADING)
526-
527-
var filePath: String? = null
528526
downloader.download(downloadUrl, assetName).collect { p ->
529527
_state.value = _state.value.copy(downloadProgressPercent = p.percent)
530528
if (p.percent == 100) {
531529
_state.value = _state.value.copy(downloadStage = DownloadStage.VERIFYING)
532530
}
533531
}
534532

535-
filePath = downloader.getDownloadedFilePath(assetName)
533+
val filePath = downloader.getDownloadedFilePath(assetName)
536534
?: throw IllegalStateException("Downloaded file not found")
537535

538536
appendLog(assetName, sizeBytes, releaseTag, "Downloaded")
@@ -544,14 +542,20 @@ class DetailsViewModel(
544542
throw IllegalStateException("Asset type .$ext not supported")
545543
}
546544

547-
saveInstalledAppToDatabase(
548-
assetName = assetName,
549-
assetUrl = downloadUrl,
550-
assetSize = sizeBytes,
551-
releaseTag = releaseTag,
552-
isUpdate = isUpdate,
553-
filePath = filePath
554-
)
545+
if (platform.type == PlatformType.ANDROID) {
546+
saveInstalledAppToDatabase(
547+
assetName = assetName,
548+
assetUrl = downloadUrl,
549+
assetSize = sizeBytes,
550+
releaseTag = releaseTag,
551+
isUpdate = isUpdate,
552+
filePath = filePath
553+
)
554+
} else {
555+
viewModelScope.launch {
556+
_events.send(DetailsEvent.OnMessage("Installer was saved into the Downloads folder"))
557+
}
558+
}
555559

556560
installer.install(filePath, ext)
557561

composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/feature/details/data/DesktopDownloader.kt

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class DesktopDownloader(
2323

2424
override fun download(url: String, suggestedFileName: String?): Flow<DownloadProgress> = channelFlow {
2525
withContext(Dispatchers.IO) {
26-
val dir = File(files.appDownloadsDir())
26+
val dir = File(files.userDownloadsDir())
2727
if (!dir.exists()) dir.mkdirs()
2828

2929
val safeName = (suggestedFileName?.takeIf { it.isNotBlank() }
@@ -72,7 +72,6 @@ class DesktopDownloader(
7272

7373
trySend(DownloadProgress(total ?: outFile.length(), total, 100))
7474
} catch (e: CancellationException) {
75-
// Delete partial file on cancellation
7675
if (outFile.exists()) {
7776
outFile.delete()
7877
Logger.d { "Deleted partial file after cancellation: ${outFile.absolutePath}" }
@@ -85,7 +84,7 @@ class DesktopDownloader(
8584
}
8685

8786
override suspend fun saveToFile(url: String, suggestedFileName: String?): String = withContext(Dispatchers.IO) {
88-
val dir = File(files.appDownloadsDir())
87+
val dir = File(files.userDownloadsDir())
8988
val safeName = (suggestedFileName?.takeIf { it.isNotBlank() }
9089
?: url.substringAfterLast('/')
9190
.ifBlank { "asset-${UUID.randomUUID()}" })
@@ -104,7 +103,7 @@ class DesktopDownloader(
104103
}
105104

106105
override suspend fun getDownloadedFilePath(fileName: String): String? = withContext(Dispatchers.IO) {
107-
val dir = File(files.appDownloadsDir())
106+
val dir = File(files.userDownloadsDir())
108107
val file = File(dir, fileName)
109108

110109
if (file.exists() && file.length() > 0) {
@@ -115,15 +114,15 @@ class DesktopDownloader(
115114
}
116115

117116
override suspend fun cancelDownload(fileName: String): Boolean = withContext(Dispatchers.IO) {
118-
val dir = File(files.appDownloadsDir())
117+
val dir = File(files.userDownloadsDir())
119118
val file = File(dir, fileName)
120119

121120
if (file.exists()) {
122121
val deleted = file.delete()
123122
if (deleted) {
124-
Logger.d { "Deleted cached file: ${file.absolutePath}" }
123+
Logger.d { "Deleted file from Downloads: ${file.absolutePath}" }
125124
} else {
126-
Logger.w { "Failed to delete cached file: ${file.absolutePath}" }
125+
Logger.w { "Failed to delete file: ${file.absolutePath}" }
127126
}
128127
deleted
129128
} else {

composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/feature/details/data/DesktopFileLocationsProvider.kt

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package zed.rainxch.githubstore.feature.details.data
22

3+
import co.touchlab.kermit.Logger
34
import zed.rainxch.githubstore.core.domain.model.PlatformType
45
import java.io.File
56
import java.nio.file.Files
@@ -59,4 +60,61 @@ class DesktopFileLocationsProvider(
5960
}
6061
}
6162
}
63+
64+
override fun userDownloadsDir(): String {
65+
val downloadsDir = when (platform) {
66+
PlatformType.WINDOWS -> {
67+
val userProfile = System.getenv("USERPROFILE")
68+
?: System.getProperty("user.home")
69+
File(userProfile, "Downloads")
70+
}
71+
PlatformType.MACOS -> {
72+
val home = System.getProperty("user.home")
73+
File(home, "Downloads")
74+
}
75+
PlatformType.LINUX -> {
76+
val xdgDownloads = getXdgDownloadsDir()
77+
if (xdgDownloads != null) {
78+
File(xdgDownloads)
79+
} else {
80+
val home = System.getProperty("user.home")
81+
File(home, "Downloads")
82+
}
83+
}
84+
else -> {
85+
File(System.getProperty("user.home"), "Downloads")
86+
}
87+
}
88+
89+
if (!downloadsDir.exists()) {
90+
downloadsDir.mkdirs()
91+
}
92+
93+
return downloadsDir.absolutePath
94+
}
95+
96+
private fun getXdgDownloadsDir(): String? {
97+
return try {
98+
val userDirsFile = File(
99+
System.getProperty("user.home"),
100+
".config/user-dirs.dirs"
101+
)
102+
103+
if (userDirsFile.exists()) {
104+
userDirsFile.readLines().forEach { line ->
105+
if (line.trim().startsWith("XDG_DOWNLOAD_DIR=")) {
106+
val path = line.substringAfter("=")
107+
.trim()
108+
.removeSurrounding("\"")
109+
.replace("\$HOME", System.getProperty("user.home"))
110+
return path
111+
}
112+
}
113+
}
114+
null
115+
} catch (e: Exception) {
116+
Logger.w { "Failed to read XDG user dirs: ${e.message}" }
117+
null
118+
}
119+
}
62120
}

0 commit comments

Comments
 (0)