How to Distribute Desktop Apps as a Solo Developer Without Security Warnings
TuBrief 편집팀
2026년 7월 15일
0
Computing/Software원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
커뮤니티의 다른 글
댓글 (0)
Log in to leave a comment
아직 작성된 글이 없습니다
원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
Log in to leave a comment
아직 작성된 글이 없습니다
If you have finished a desktop app that runs well locally, your development is only half done. The real challenge begins the moment a user clicks your download link and is met with a red warning screen saying "File corrupted" or "Windows protected your PC."
Overcoming OS security barriers, managing build speeds, and creating an auto-update system that pulls the latest version whenever the user opens the app is more exhausting than you might think. Even if you chose Tauri v2 because it is lighter than Electron, the realistic problems of the distribution process remain. Here is a summary of how solo developers or small teams can cleanly distribute products without wasting unnecessary money and time.
Desktop apps without code signing are treated as malware at the operating system level. To avoid displaying security warnings on user PCs, you need money and paperwork.
To distribute on macOS, a $99/year Apple Developer Program membership is mandatory. Once you have an account, you must create an src-tauri/Entitlements.plist file that defines memory security exception permissions so the Tauri webview can run properly. If this setting is missing, the app will crash as soon as it is launched.
`xml
com.apple.security.cs.allow-jit
com.apple.security.cs.allow-unsigned-executable-memory
`
Specify this file in the bundle options of src-tauri/tauri.conf.json.
`json
{
"bundle": {
"macOS": {
"signingIdentity": "Developer ID Application: Your Name (TEAMID)",
"entitlements": "./Entitlements.plist",
"minimumSystemVersion": "11.0",
"dmg": {
"appPosition": { "x": 180, "y": 170 },
"applicationFolderPosition": { "x": 480, "y": 170 }
}
}
}
}
`
To pass the Windows SmartScreen filter, you used to need an EV (Extended Validation) certificate, which cost 700 per year and was issued in the form of a physical USB token. Aside from the cost, it is excessively cumbersome for an individual to manage.
The alternative is Azure Trusted Signing (ATS), Microsoft's cloud-based signing service. By paying a subscription fee of about $9.99 per month, the signing is handled inside an HSM cloud managed by Microsoft, so there is no need to store a physical key.
AZURE_TENANT_ID, CLIENT_ID, CLIENT_SECRET) and ATS information into GitHub Actions Secrets.sign-tool during the build process to apply a digital signature to the MSI or EXE files compiled by Tauri.Apps signed this way bypass Windows SmartScreen warnings from the moment of the first download, allowing you to retain users who might otherwise drop off at the installation stage.
Tauri is lightweight, but the build process requires running the entire Rust compiler and native toolchains for each OS. Even if it works on your computer, it is common to run into linker errors on other team members' computers or have distribution builds break due to local dependency contamination. Distribution builds must be run in an isolated CI/CD pipeline to be safe.
The problem is that the default hosted runners on GitHub Actions lack the specifications to build Rust. If your structure involves downloading dependencies and building from scratch every time, a release build can easily take more than 10 minutes.
In this case, instead of using actions/cache, which simply compresses files to send/receive to the cloud, combining a dedicated cache plugin that targets NVMe high-performance storage (swatinem/rust-cache) or dedicated hosted runners (Namespace, Depot, etc.) will change your speed.
Based on build logs for the open-source music player project spotify-player, the performance comparison between a standard GitHub runner and a dedicated runner with local volume caching is as follows:
| Platform and Cache Config | Standard GitHub Runner Duration | Duration with Cache Optimization | Build Time Reduction |
|---|---|---|---|
| Ubuntu Linux | 9m 31s | 34s | 94.0% reduction |
| macOS Darwin | 9m 31s | 27s | 95.2% reduction |
| Windows MSVC | 9m 31s | 44s | 92.2% reduction |
| Workflow Cost | $0.44 per run | $0.074 per run | 83.1% savings |
Just by linking a persistent volume cache infrastructure, your development team's build wait time is reduced by at least 40%.
Specify the distribution automation settings in .github/workflows/publish.yml as follows:
`yaml
jobs:
build-binaries:
strategy:
matrix:
platform: [macos-latest, windows-latest]
runs-on: ${{ matrix.platform }}
# ... After build steps, call tauri-action
`
By placing tauri-apps/tauri-action at the very end of the workflow, signed installers for both OSs are automatically registered to GitHub Release Drafts whenever you push a new tag.
When packaging an installer for Windows, you must decide how to install the WebView2 engine, which is the webview host. If you can guarantee an internet connection and need to minimize the download file size, the downloadBootstrapper method, which does not increase the bundle size, is safe. Conversely, if you are targeting air-gapped or offline environments, it is safer to include the offlineInstaller, even if it adds about 127MB to the installation file.
The way you handle data when operating a Tauri-based app is also important. It is dangerous to simply put data into browser storage like IndexedDB or LocalStorage.
In fact, during the transition from Tauri v1 to v2, there was an internal change where the Windows environment webview domain schema changed from [https://tauri.localhost](https://tauri.localhost) to [http://tauri.localhost](http://tauri.localhost). Because of this, the browser cache path was forcibly switched, leading to many cases of existing data being lost.
To prevent the disaster of data being reset after distribution, you should store core information directly in the native file system as an SQLite file instead of webview storage. Using the appDataDir API in Tauri v2 will automatically find a safe sandboxing path that conforms to OS standards.
C:\Users\<UserName>\AppData\Roaming\<BundleIdentifier>/Users/<UserName>/Library/Application Support/<BundleIdentifier>An example of intervening in the app lifecycle within the Rust code (src-tauri/src/lib.rs), which is the Tauri v2 backend, to bind the SQLite database to a safe area and run schema migrations is as follows:
`rust
use std::fs;
use tauri::Manager;
use tauri_plugin_sql::{Migration, MigrationKind};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let database_migrations = vec![
Migration {
version: 1,
description: "initialize_user_profiles_table",
sql: "CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);",
kind: MigrationKind::Up,
}
];
tauri::Builder::default()
.setup(|app| {
let local_app_dir = app.path().app_data_dir()
.expect("Critical: Could not resolve target operating system app data path.");
if !local_app_dir.exists() {
fs::create_dir_all(&local_app_dir)
.expect("Critical: Failed to establish persistent storage directory structure.");
}
Ok(())
})
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations("sqlite:users.db", database_migrations)
.build()
)
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
`
By configuring it this way, even if the internal cache of Electron or Chromium webview is wiped out due to an auto-update or reinstallation, the actual user database is preserved safely.
Inducing users to visit your homepage every time to download a new version increases churn. You should build a structure that quietly serves update files by combining cloud object storage and a CDN.
A combination of Cloudflare R2 and AWS CloudFront is efficient as a distribution server. Cloudflare R2 has no egress fees, so you can keep the network traffic costs incurred when releasing large update files to zero.
The metadata file (latest.json) that the client checks to see if a new version has been released must not be cached by CDNs or browsers. You must specify the following policy in the response header:
http Cache-Control: no-cache, no-store, must-revalidate
On the other hand, since the actual installation binary files are in an immutable state containing a unique hash value, you should set them to be held by the CDN for as long as possible to reduce the traffic burden on the origin server.
http Cache-Control: public, max-age=31536000, immutable
In Tauri v2, the location of update-related options has moved under the plugins.updater block. Below is the tauri.conf.json configuration specification:
`json
{
"bundle": {
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"active": true,
"endpoints": [
"https://cdn.myapp.com/releases/latest.json"
],
"dialog": false,
"pubkey": "dW5zaWduZWQgYm91bmRmaXg...",
"windows": {
"installMode": "passive"
}
}
}
}
`
To make users update without having to click annoying confirmation windows in a Windows environment, you should set installMode to passive or quiet. The passive mode displays only a quiet progress bar instead of an installation wizard window, then completes the replacement quietly.
Once configuration is complete, link @tauri-apps/plugin-updater and @tauri-apps/plugin-process in the frontend to add logic that checks for new patches at app launch and induces a reboot:
`typescript
import { check } from "@tauri-apps/plugin-updater";
import { ask } from "@tauri-apps/plugin-dialog";
import { relaunch } from "@tauri-apps/plugin-process";
export async function runBackgroundUpdater(): Promise {
try {
const updatePayload = await check();
if (updatePayload && updatePayload.available) {
const userResponse = await ask(
`A new version [v${updatePayload.version}] is available. Would you like to update and restart the app now?`,
{
title: "Software Auto-Update Notice",
kind: "info",
okLabel: "Install Update and Restart",
cancelLabel: "Apply Later"
}
);
if (userResponse) {
await updatePayload.downloadAndInstall();
await relaunch();
}
}
} catch (error) {
console.error("Exception occurred during auto-update check:", error);
}
}
`
By embedding this function in the top-level React component or during the initial mount phase of a view, users will always use the latest version of the software without having to manually dig through your homepage.
appDataDir path controlled by the native side and run long-term schema migrations so that data does not get tangled during app updates.