mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
feat: client auto-update with Ed25519 signing and dynamic server URL
- Add tauri-plugin-updater and tauri-plugin-process for in-app updates
- Rust commands (check_client_update, download_and_install_update) build
updater with dynamic endpoint at runtime for self-hosted compatibility
- Server endpoint GET /api/v1/client-update/{target}/{version} translates
GitHub Releases into Tauri updater JSON format with .sig content
- UpdateNotifier banner component with install/dismiss controls
- CI workflow produces signed .nsis.zip + .sig updater artifacts
- Self-signed TLS support via dangerousAcceptInvalidCerts config
This commit is contained in:
@@ -48,31 +48,60 @@ jobs:
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: Client/tauri-client
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: npm run tauri build
|
||||
|
||||
- name: Locate installer
|
||||
id: installer
|
||||
- name: Locate artifacts
|
||||
id: artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
INSTALLER=$(find Client/tauri-client/src-tauri/target/release/bundle/nsis -name "*.exe" | head -1)
|
||||
echo "path=$INSTALLER" >> "$GITHUB_OUTPUT"
|
||||
echo "name=$(basename $INSTALLER)" >> "$GITHUB_OUTPUT"
|
||||
NSIS_DIR="Client/tauri-client/src-tauri/target/release/bundle/nsis"
|
||||
INSTALLER=$(find "$NSIS_DIR" -name "*.exe" | head -1)
|
||||
echo "installer_path=$INSTALLER" >> "$GITHUB_OUTPUT"
|
||||
echo "installer_name=$(basename $INSTALLER)" >> "$GITHUB_OUTPUT"
|
||||
# Updater artifacts (produced when TAURI_SIGNING_PRIVATE_KEY is set)
|
||||
NSIS_ZIP=$(find "$NSIS_DIR" -name "*_x64-setup.nsis.zip" ! -name "*.sig" | head -1)
|
||||
NSIS_SIG=$(find "$NSIS_DIR" -name "*_x64-setup.nsis.zip.sig" | head -1)
|
||||
echo "nsis_zip=${NSIS_ZIP:-}" >> "$GITHUB_OUTPUT"
|
||||
echo "nsis_sig=${NSIS_SIG:-}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate SHA256 checksums
|
||||
shell: pwsh
|
||||
run: |
|
||||
$lines = @()
|
||||
$serverHash = (Get-FileHash -Path Server/chatserver.exe -Algorithm SHA256).Hash.ToLower()
|
||||
$installerPath = "${{ steps.installer.outputs.path }}"
|
||||
$installerName = "${{ steps.installer.outputs.name }}"
|
||||
$lines += "$serverHash chatserver.exe"
|
||||
$installerPath = "${{ steps.artifacts.outputs.installer_path }}"
|
||||
$installerName = "${{ steps.artifacts.outputs.installer_name }}"
|
||||
$clientHash = (Get-FileHash -Path $installerPath -Algorithm SHA256).Hash.ToLower()
|
||||
"$serverHash chatserver.exe`n$clientHash $installerName" | Out-File -FilePath checksums.sha256 -Encoding utf8 -NoNewline
|
||||
$lines += "$clientHash $installerName"
|
||||
$nsisZip = "${{ steps.artifacts.outputs.nsis_zip }}"
|
||||
if ($nsisZip -and (Test-Path $nsisZip)) {
|
||||
$zipName = Split-Path $nsisZip -Leaf
|
||||
$zipHash = (Get-FileHash -Path $nsisZip -Algorithm SHA256).Hash.ToLower()
|
||||
$lines += "$zipHash $zipName"
|
||||
}
|
||||
$lines -join "`n" | Out-File -FilePath checksums.sha256 -Encoding utf8 -NoNewline
|
||||
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: >-
|
||||
gh release create ${{ github.ref_name }}
|
||||
--generate-notes
|
||||
Server/chatserver.exe
|
||||
"${{ steps.installer.outputs.path }}"
|
||||
checksums.sha256
|
||||
shell: bash
|
||||
run: |
|
||||
ASSETS=(
|
||||
Server/chatserver.exe
|
||||
"${{ steps.artifacts.outputs.installer_path }}"
|
||||
checksums.sha256
|
||||
)
|
||||
# Include updater artifacts if signing key was available
|
||||
if [ -n "${{ steps.artifacts.outputs.nsis_zip }}" ]; then
|
||||
ASSETS+=("${{ steps.artifacts.outputs.nsis_zip }}")
|
||||
fi
|
||||
if [ -n "${{ steps.artifacts.outputs.nsis_sig }}" ]; then
|
||||
ASSETS+=("${{ steps.artifacts.outputs.nsis_sig }}")
|
||||
fi
|
||||
gh release create ${{ github.ref_name }} \
|
||||
--generate-notes \
|
||||
"${ASSETS[@]}"
|
||||
|
||||
Generated
+21
-1
@@ -15,7 +15,9 @@
|
||||
"@tauri-apps/plugin-http": "^2.5.7",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-store": "^2"
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1",
|
||||
@@ -1500,6 +1502,15 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-process": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
|
||||
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-store": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.2.tgz",
|
||||
@@ -1509,6 +1520,15 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-updater": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.0.tgz",
|
||||
"integrity": "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
"@tauri-apps/plugin-http": "^2.5.7",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-store": "^2"
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+253
-2
@@ -47,6 +47,15 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -733,6 +742,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
@@ -1033,6 +1053,17 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"libredox",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -2104,7 +2135,10 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"libc",
|
||||
"plain",
|
||||
"redox_syscall 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2227,6 +2261,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2448,6 +2488,18 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -2504,6 +2556,12 @@ dependencies = [
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -2520,6 +2578,20 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-osa-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owncord-client"
|
||||
version = "0.1.0"
|
||||
@@ -2537,9 +2609,12 @@ dependencies = [
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"url",
|
||||
"windows 0.58.0",
|
||||
]
|
||||
|
||||
@@ -2592,7 +2667,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"smallvec",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
@@ -2825,6 +2900,12 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.8.0"
|
||||
@@ -3208,6 +3289,15 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
@@ -3325,15 +3415,20 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -3425,6 +3520,18 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
@@ -3435,6 +3542,33 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
@@ -3467,6 +3601,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -3524,6 +3667,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.24.0"
|
||||
@@ -3843,7 +4009,7 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"raw-window-handle",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -4079,6 +4245,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -4336,6 +4513,16 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-process"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
|
||||
dependencies = [
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-store"
|
||||
version = "2.4.2"
|
||||
@@ -4352,6 +4539,39 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
"infer",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest 0.13.2",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.10.1"
|
||||
@@ -5277,6 +5497,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
@@ -6044,6 +6273,16 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xkeysym"
|
||||
version = "0.2.1"
|
||||
@@ -6214,6 +6453,18 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.13.0",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -22,6 +22,9 @@ tauri-plugin-http = { version = "2.5.7", features = ["rustls-tls", "dangerous-se
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
url = "2"
|
||||
tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = "0.3.32"
|
||||
tokio = { version = "1", features = ["sync"] }
|
||||
|
||||
@@ -65,6 +65,8 @@
|
||||
"http:allow-fetch-cancel",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"updater:default",
|
||||
"process:allow-restart",
|
||||
"fs:default",
|
||||
{
|
||||
"identifier": "fs:allow-write-file",
|
||||
|
||||
@@ -2,6 +2,7 @@ mod commands;
|
||||
mod credentials;
|
||||
mod hotkeys;
|
||||
mod tray;
|
||||
mod update_commands;
|
||||
mod ws_proxy;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
@@ -14,6 +15,8 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.manage(ws_proxy::WsState::new())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_settings,
|
||||
@@ -27,6 +30,8 @@ pub fn run() {
|
||||
credentials::save_credential,
|
||||
credentials::load_credential,
|
||||
credentials::delete_credential,
|
||||
update_commands::check_client_update,
|
||||
update_commands::download_and_install_update,
|
||||
])
|
||||
.setup(|app| {
|
||||
tray::create_tray(app.handle())?;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
use serde::Serialize;
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UpdateCheckResult {
|
||||
pub available: bool,
|
||||
pub version: Option<String>,
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
/// Check for a client update using the given server URL to build the endpoint
|
||||
/// dynamically. This is required because OwnCord is self-hosted and the
|
||||
/// server address varies per user.
|
||||
#[tauri::command]
|
||||
pub async fn check_client_update(
|
||||
app: AppHandle,
|
||||
server_url: String,
|
||||
) -> Result<UpdateCheckResult, String> {
|
||||
let current_version = app
|
||||
.config()
|
||||
.version
|
||||
.clone()
|
||||
.unwrap_or_else(|| "0.0.0".to_string());
|
||||
|
||||
let endpoint = format!(
|
||||
"{}/api/v1/client-update/{{{{target}}}}/{}",
|
||||
server_url.trim_end_matches('/'),
|
||||
current_version,
|
||||
);
|
||||
|
||||
let url: url::Url = endpoint
|
||||
.parse()
|
||||
.map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?;
|
||||
|
||||
let updater = app
|
||||
.updater_builder()
|
||||
.endpoints(vec![url])
|
||||
.map_err(|e| format!("failed to set endpoints: {e}"))?
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build updater: {e}"))?;
|
||||
|
||||
let update = updater
|
||||
.check()
|
||||
.await
|
||||
.map_err(|e| format!("update check failed: {e}"))?;
|
||||
|
||||
match update {
|
||||
Some(u) => Ok(UpdateCheckResult {
|
||||
available: true,
|
||||
version: Some(u.version.clone()),
|
||||
body: Some(u.body.clone().unwrap_or_default()),
|
||||
}),
|
||||
None => Ok(UpdateCheckResult {
|
||||
available: false,
|
||||
version: None,
|
||||
body: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Download and install a pending update, then signal the frontend.
|
||||
/// The frontend should call `relaunch()` from @tauri-apps/plugin-process
|
||||
/// after this completes.
|
||||
#[tauri::command]
|
||||
pub async fn download_and_install_update(
|
||||
app: AppHandle,
|
||||
server_url: String,
|
||||
) -> Result<(), String> {
|
||||
let current_version = app
|
||||
.config()
|
||||
.version
|
||||
.clone()
|
||||
.unwrap_or_else(|| "0.0.0".to_string());
|
||||
|
||||
let endpoint = format!(
|
||||
"{}/api/v1/client-update/{{{{target}}}}/{}",
|
||||
server_url.trim_end_matches('/'),
|
||||
current_version,
|
||||
);
|
||||
|
||||
let url: url::Url = endpoint
|
||||
.parse()
|
||||
.map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?;
|
||||
|
||||
let updater = app
|
||||
.updater_builder()
|
||||
.endpoints(vec![url])
|
||||
.map_err(|e| format!("failed to set endpoints: {e}"))?
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build updater: {e}"))?;
|
||||
|
||||
let update = updater
|
||||
.check()
|
||||
.await
|
||||
.map_err(|e| format!("update check failed: {e}"))?;
|
||||
|
||||
match update {
|
||||
Some(u) => {
|
||||
u.download_and_install(|_chunk_len, _total| {}, || {})
|
||||
.await
|
||||
.map_err(|e| format!("download/install failed: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
None => Err("no update available".into()),
|
||||
}
|
||||
}
|
||||
@@ -42,5 +42,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {}
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxMkZGMTUzMDBBNkFCNDAKUldSQXE2WUFVL0V2Z2NjekFXaVI1elpQYVBmOUdKNmZrTzZwRC80RHlBMkQyYzZWYXdTK00xK0wK",
|
||||
"endpoints": [],
|
||||
"dangerousAcceptInvalidCerts": true,
|
||||
"dangerousAcceptInvalidHostnames": true,
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// UpdateNotifier — shows a non-modal banner when a client update is available.
|
||||
// Mounts at the top of the main page and allows the user to update or dismiss.
|
||||
|
||||
import { createElement, appendChildren } from "@lib/dom";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import { checkForUpdate, downloadAndInstallUpdate } from "@lib/updater";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
|
||||
const log = createLogger("update-notifier");
|
||||
|
||||
export interface UpdateNotifierOptions {
|
||||
readonly serverUrl: string;
|
||||
}
|
||||
|
||||
export function createUpdateNotifier(options: UpdateNotifierOptions): MountableComponent {
|
||||
const { serverUrl } = options;
|
||||
let container: Element | null = null;
|
||||
let banner: HTMLDivElement | null = null;
|
||||
let dismissed = false;
|
||||
|
||||
async function performCheck(): Promise<void> {
|
||||
if (dismissed) return;
|
||||
|
||||
const result = await checkForUpdate(serverUrl);
|
||||
if (!result.available || result.version === null) return;
|
||||
|
||||
showBanner(result.version, result.body ?? "");
|
||||
}
|
||||
|
||||
function showBanner(version: string, notes: string): void {
|
||||
if (container === null || banner !== null) return;
|
||||
|
||||
banner = createElement("div", { class: "update-banner" });
|
||||
|
||||
const text = createElement("span", { class: "update-banner-text" },
|
||||
`Update v${version} available`);
|
||||
|
||||
const updateBtn = createElement("button", { class: "update-banner-btn update-banner-install" },
|
||||
"Update Now");
|
||||
updateBtn.addEventListener("click", () => {
|
||||
void installUpdate();
|
||||
});
|
||||
|
||||
const laterBtn = createElement("button", { class: "update-banner-btn update-banner-later" },
|
||||
"Later");
|
||||
laterBtn.addEventListener("click", () => {
|
||||
dismissed = true;
|
||||
removeBanner();
|
||||
});
|
||||
|
||||
appendChildren(banner, text, updateBtn, laterBtn);
|
||||
container.prepend(banner);
|
||||
}
|
||||
|
||||
async function installUpdate(): Promise<void> {
|
||||
if (banner === null) return;
|
||||
|
||||
// Replace banner content with progress indicator
|
||||
while (banner.firstChild) banner.removeChild(banner.firstChild);
|
||||
const progress = createElement("span", { class: "update-banner-text" },
|
||||
"Downloading update...");
|
||||
banner.appendChild(progress);
|
||||
|
||||
try {
|
||||
await downloadAndInstallUpdate(serverUrl);
|
||||
// App will relaunch — this code won't execute after relaunch()
|
||||
} catch (err) {
|
||||
log.error("Update install failed", { error: String(err) });
|
||||
while (banner.firstChild) banner.removeChild(banner.firstChild);
|
||||
const errorText = createElement("span", { class: "update-banner-text" },
|
||||
"Update failed. Please try again later.");
|
||||
const dismissBtn = createElement("button", { class: "update-banner-btn update-banner-later" },
|
||||
"Dismiss");
|
||||
dismissBtn.addEventListener("click", () => {
|
||||
dismissed = true;
|
||||
removeBanner();
|
||||
});
|
||||
appendChildren(banner, errorText, dismissBtn);
|
||||
}
|
||||
}
|
||||
|
||||
function removeBanner(): void {
|
||||
if (banner !== null) {
|
||||
banner.remove();
|
||||
banner = null;
|
||||
}
|
||||
}
|
||||
|
||||
function mount(target: Element): void {
|
||||
container = target;
|
||||
// Delay the check slightly so the main UI renders first
|
||||
setTimeout(() => { void performCheck(); }, 3000);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
removeBanner();
|
||||
container = null;
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// updater.ts — Client auto-update service.
|
||||
// Uses custom Tauri commands that build the updater with a dynamic server URL
|
||||
// at runtime (required because OwnCord is self-hosted).
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { createLogger } from "@lib/logger";
|
||||
|
||||
const log = createLogger("updater");
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
readonly available: boolean;
|
||||
readonly version: string | null;
|
||||
readonly body: string | null;
|
||||
}
|
||||
|
||||
/** Check if a newer client version is available on the connected server. */
|
||||
export async function checkForUpdate(serverUrl: string): Promise<UpdateCheckResult> {
|
||||
try {
|
||||
const result = await invoke<UpdateCheckResult>("check_client_update", {
|
||||
serverUrl,
|
||||
});
|
||||
if (result.available) {
|
||||
log.info("Update available", { version: result.version });
|
||||
} else {
|
||||
log.debug("No update available");
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
log.error("Update check failed", { error: String(err) });
|
||||
return { available: false, version: null, body: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** Download and install a pending update, then relaunch the app. */
|
||||
export async function downloadAndInstallUpdate(serverUrl: string): Promise<void> {
|
||||
log.info("Downloading and installing update...");
|
||||
await invoke("download_and_install_update", { serverUrl });
|
||||
log.info("Update installed, relaunching...");
|
||||
await relaunch();
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
createInviteManagerController,
|
||||
createPinnedPanelController,
|
||||
} from "./main-page/OverlayManagers";
|
||||
import { createUpdateNotifier } from "@components/UpdateNotifier";
|
||||
|
||||
const log = createLogger("main-page");
|
||||
|
||||
@@ -685,6 +686,14 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
// Wire voice error callback to toast
|
||||
setVoiceOnError((msg) => toast?.show(msg, "error"));
|
||||
|
||||
// Auto-update notifier — checks server for newer client version
|
||||
if (apiConfig.host) {
|
||||
const serverUrl = `https://${apiConfig.host}`;
|
||||
const updateNotifier = createUpdateNotifier({ serverUrl });
|
||||
updateNotifier.mount(root);
|
||||
children.push(updateNotifier);
|
||||
}
|
||||
|
||||
container.appendChild(root);
|
||||
|
||||
// --- Subscribe to channel changes ---
|
||||
|
||||
@@ -468,6 +468,23 @@
|
||||
}
|
||||
.msg-file-download:hover { background: var(--bg-hover); color: var(--text-normal); }
|
||||
|
||||
/* ── Update Banner ── */
|
||||
.update-banner {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 8px 16px; background: var(--accent);
|
||||
color: #fff; font-size: 13px; font-weight: 500;
|
||||
flex-shrink: 0; z-index: 100;
|
||||
}
|
||||
.update-banner-text { flex: 1; }
|
||||
.update-banner-btn {
|
||||
padding: 4px 12px; border-radius: var(--radius-sm);
|
||||
font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
border: none; transition: opacity .15s;
|
||||
}
|
||||
.update-banner-btn:hover { opacity: 0.85; }
|
||||
.update-banner-install { background: #fff; color: var(--accent); }
|
||||
.update-banner-later { background: rgba(255,255,255,0.2); color: #fff; }
|
||||
|
||||
/* System message */
|
||||
.system-msg {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// client_update.go serves Tauri-compatible update metadata so the desktop
|
||||
// client can check for new versions and self-update.
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/owncord/server/updater"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// tauriPlatformResponse is the per-platform entry in the Tauri updater JSON.
|
||||
type tauriPlatformResponse struct {
|
||||
Signature string `json:"signature"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// tauriUpdateResponse is the JSON shape the Tauri updater plugin expects.
|
||||
type tauriUpdateResponse struct {
|
||||
Version string `json:"version"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
PubDate string `json:"pub_date,omitempty"`
|
||||
Platforms map[string]tauriPlatformResponse `json:"platforms"`
|
||||
}
|
||||
|
||||
// MountClientUpdateRoute adds the unauthenticated client-update endpoint.
|
||||
// The route is outside the auth middleware because the client needs to check
|
||||
// for updates before (or without) logging in.
|
||||
func MountClientUpdateRoute(r chi.Router, u *updater.Updater) {
|
||||
r.Get("/api/v1/client-update/{target}/{current_version}", handleClientUpdate(u))
|
||||
}
|
||||
|
||||
func handleClientUpdate(u *updater.Updater) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
target := chi.URLParam(r, "target")
|
||||
currentVersion := chi.URLParam(r, "current_version")
|
||||
|
||||
if target == "" || currentVersion == "" {
|
||||
http.Error(w, "missing target or current_version", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := u.CheckForUpdate(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "failed to check for updates", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
// Compare versions — return 204 if no update available.
|
||||
cv := ensureV(currentVersion)
|
||||
lv := ensureV(info.Latest)
|
||||
if semver.Compare(cv, lv) >= 0 {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the .nsis.zip and .nsis.zip.sig assets from the release.
|
||||
clientAssets := u.FindClientAssets()
|
||||
nsisURL := clientAssets.InstallerURL
|
||||
sigURL := clientAssets.SignatureURL
|
||||
if nsisURL == "" || sigURL == "" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch the signature file content (small text file).
|
||||
sigContent, err := u.FetchTextAsset(r.Context(), sigURL)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to fetch signature", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
resp := tauriUpdateResponse{
|
||||
Version: strings.TrimPrefix(info.Latest, "v"),
|
||||
Notes: info.ReleaseNotes,
|
||||
Platforms: map[string]tauriPlatformResponse{
|
||||
target: {
|
||||
Signature: strings.TrimSpace(sigContent),
|
||||
URL: nsisURL,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
}
|
||||
|
||||
// ensureV returns a version string with a "v" prefix for semver comparison.
|
||||
func ensureV(v string) string {
|
||||
if strings.HasPrefix(v, "v") {
|
||||
return v
|
||||
}
|
||||
return "v" + v
|
||||
}
|
||||
@@ -83,6 +83,9 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string) http.Handler {
|
||||
u := updater.NewUpdater(ver, cfg.GitHub.Token, "J3vb", "OwnCord")
|
||||
r.Mount("/admin", admin.NewHandler(database, ver, hub, u))
|
||||
|
||||
// Client auto-update endpoint (unauthenticated).
|
||||
MountClientUpdateRoute(r, u)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,19 @@ type UpdateInfo struct {
|
||||
DownloadURL string `json:"download_url"`
|
||||
ChecksumURL string `json:"checksum_url"`
|
||||
ReleaseNotes string `json:"release_notes"`
|
||||
Assets []Asset `json:"assets,omitempty"`
|
||||
}
|
||||
|
||||
// Asset is a simplified release asset with name and download URL.
|
||||
type Asset struct {
|
||||
Name string `json:"name"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
}
|
||||
|
||||
// ClientAssets holds the URLs for Tauri client update artifacts.
|
||||
type ClientAssets struct {
|
||||
InstallerURL string
|
||||
SignatureURL string
|
||||
}
|
||||
|
||||
// releaseResponse mirrors the subset of GitHub's release API we need.
|
||||
@@ -142,7 +155,12 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateInfo, error) {
|
||||
updateAvailable := semver.Compare(currentV, latestV) < 0
|
||||
|
||||
var downloadURL, checksumURL string
|
||||
assets := make([]Asset, 0, len(release.Assets))
|
||||
for _, asset := range release.Assets {
|
||||
assets = append(assets, Asset{
|
||||
Name: asset.Name,
|
||||
DownloadURL: asset.BrowserDownloadURL,
|
||||
})
|
||||
switch asset.Name {
|
||||
case binaryAsset:
|
||||
downloadURL = asset.BrowserDownloadURL
|
||||
@@ -159,6 +177,7 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateInfo, error) {
|
||||
DownloadURL: downloadURL,
|
||||
ChecksumURL: checksumURL,
|
||||
ReleaseNotes: release.Body,
|
||||
Assets: assets,
|
||||
}
|
||||
|
||||
u.mu.Lock()
|
||||
@@ -280,6 +299,38 @@ func (u *Updater) fetchBody(ctx context.Context, url string) ([]byte, error) {
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// FindClientAssets scans the cached release assets for the Tauri NSIS
|
||||
// installer zip and its Ed25519 signature file.
|
||||
func (u *Updater) FindClientAssets() ClientAssets {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
|
||||
if u.cache == nil {
|
||||
return ClientAssets{}
|
||||
}
|
||||
|
||||
var ca ClientAssets
|
||||
for _, a := range u.cache.Assets {
|
||||
switch {
|
||||
case strings.HasSuffix(a.Name, "_x64-setup.nsis.zip.sig"):
|
||||
ca.SignatureURL = a.DownloadURL
|
||||
case strings.HasSuffix(a.Name, "_x64-setup.nsis.zip"):
|
||||
ca.InstallerURL = a.DownloadURL
|
||||
}
|
||||
}
|
||||
return ca
|
||||
}
|
||||
|
||||
// FetchTextAsset downloads a small text asset (e.g. a .sig file) and returns
|
||||
// its content as a string.
|
||||
func (u *Updater) FetchTextAsset(ctx context.Context, url string) (string, error) {
|
||||
data, err := u.fetchBody(ctx, url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// downloadFile downloads the content at url and writes it to destPath.
|
||||
func (u *Updater) downloadFile(ctx context.Context, url, destPath string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
|
||||
Reference in New Issue
Block a user