Packaging app for Windows
Instructions for packaging a Flet app into a Windows application.
This guide provides detailed Windows-specific information. Complementary and more general information is available here.
For a PyInstaller-based way to package desktop apps — no Visual
Studio or Flutter toolchain required — see flet pack.
Prerequisites
Visual Studio
Visual Studio (2022 or 2026) is required with the Desktop development with C++ workload installed.
Follow this guide for instructions on downloading and installing correct Visual Studio components for Flutter desktop development.
flet build windows
This command can be run on Windows only.
Builds a Windows application.
Windows on ARM
On an ARM64 PC, such as a Snapdragon laptop, flet build windows builds an x64
app, which runs there through Windows' built-in x64 emulation. Flet's Python
runtime for Windows is x64-only, so Flet builds with its own Flutter SDK, which
is x64 as well, even when an ARM64 Flutter SDK is on PATH. The first start of
the app takes noticeably longer under emulation.
App termination
Closing the window terminates the app immediately, without running Python atexit
handlers, C++ static destructors, or flushing writes that have not yet reached the
operating system. See How a built app terminates.
Distributing
flet build windows leaves a self-contained folder in build\windows: your
app's .exe next to the DLLs it loads and the data, DLLs, Lib,
site-packages and app folders. The PCs your app runs on need no Python, and
Flet itself needs no Visual C++ Redistributable, because msvcp140.dll,
vcruntime140.dll and vcruntime140_1.dll ship in the folder. A package with
native code that needs other Visual C++ DLLs, such as msvcp140_1.dll or
vcomp140.dll, and doesn't bundle them needs the Redistributable on the user's
PC. A folder of DLLs isn't something an end user can download and double-click,
though, so wrap it in one of the formats below.
Two rules apply to every format:
- Keep the folder together: The
.exeloads its DLLs,dataand Python runtime from its own folder, so copied on its own it fails withThe code execution cannot proceed because battery_plus_plugin.dll was not found. The folder itself can live anywhere, includingC:\Program Files, where the running app has no write access, since Flet writes nothing into its own folder. Make sure your code doesn't either: save files inFLET_APP_STORAGE_DATA, which is also the app's working directory, and never next to__file__, which points into the folder. - Keep the company and product
names stable: The app keeps its data in
%APPDATA%\<company>\<product>and its console log in%LOCALAPPDATA%\<company>\<product>. Rename either between releases and the upgraded app starts with empty storage, while the old data stays behind in the previous folder. Set the company name before your first release: without one, it is Your Company.
- Installer (Inno Setup)
- zip archive
Inno Setup turns the folder into a single
setup.exe that installs the app, adds it to the Start menu, registers an
uninstaller under Settings → Apps, and upgrades an existing installation in
place.
Inno Setup is free to use, commercially too. Since version 6.5, commercial users are asked, though not required, to buy a license.
Install Inno Setup 6.6 or later, from its download page or with winget:
winget install --id JRSoftware.InnoSetup -e
This installs it for your user only, in %LOCALAPPDATA%\Programs\Inno Setup 6,
even from an administrator terminal. Add --scope machine to install it for all
users, in C:\Program Files (x86)\Inno Setup 6. Neither adds it to PATH.
Inno Setup 7 (JRSoftware.InnoSetup.7) compiles the script below unchanged, and
installs into an Inno Setup 7 folder instead.
Save the script below as installer.iss in your project's root directory,
next to pyproject.toml, since its paths are relative to that directory. Then
edit the two values at the top (AppId and AppExe), and compile it from
that directory after each flet build windows:
& "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe" installer.iss
Adjust the path for an all-users install or for Inno Setup 7. You can also open
the script in the Inno Setup Compiler and choose Build → Compile. Either way
the installer lands in build\installer, named after your artifact name and
version, for example my_app-1.0.0-setup.exe.
#define AppId "com.example.my_app"
#define AppExe "my_app.exe"
#define BuildDir "build\windows"
#define AppExePath AddBackslash(SourcePath) + BuildDir + "\" + AppExe
#define ArtifactName Copy(AppExe, 1, Len(AppExe) - 4)
#if !FileExists(AppExePath)
#error AppExe is not in BuildDir: run "flet build windows" first, or fix the values above.
#endif
#define AppName GetStringFileInfo(AppExePath, "ProductName")
#define AppPublisher GetStringFileInfo(AppExePath, "CompanyName")
#define FullVersion GetStringFileInfo(AppExePath, "ProductVersion")
#define AppVersion Copy(FullVersion, 1, Pos("+", FullVersion + "+") - 1)
[Setup]
AppId={#AppId}
AppName={#AppName}
AppVersion={#AppVersion}
AppPublisher={#AppPublisher}
VersionInfoVersion={#GetVersionNumbersString(AppExePath)}
UninstallDisplayName={#AppName}
UninstallDisplayIcon={app}\{#AppExe}
DefaultDirName={autopf}\{code:SafeAppName}
DisableProgramGroupPage=yes
PrivilegesRequired=lowest
PrivilegesRequiredOverridesAllowed=dialog
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
WizardStyle=modern dynamic
SetupIconFile=build\flutter\windows\runner\resources\app_icon.ico
OutputDir=build\installer
OutputBaseFilename={#StringChange(ArtifactName, " ", "-")}-{#AppVersion}-setup
SolidCompression=yes
[Tasks]
Name: desktopicon; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[InstallDelete]
Type: filesandordirs; Name: "{app}\app"; Check: IsUpgrade
Type: filesandordirs; Name: "{app}\data"; Check: IsUpgrade
Type: filesandordirs; Name: "{app}\DLLs"; Check: IsUpgrade
Type: filesandordirs; Name: "{app}\Lib"; Check: IsUpgrade
Type: filesandordirs; Name: "{app}\site-packages"; Check: IsUpgrade
Type: files; Name: "{app}\*.dll"; Check: IsUpgrade
[Files]
Source: "{#BuildDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
Name: "{autoprograms}\{code:SafeAppName}"; Filename: "{app}\{#AppExe}"
Name: "{autodesktop}\{code:SafeAppName}"; Filename: "{app}\{#AppExe}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#AppExe}"; Description: "{cm:LaunchProgram,{#StringChange(AppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
[Code]
function SafeAppName(Param: String): String;
var
I: Integer;
begin
Result := '{#StringChange(AppName, "'", "''")}';
for I := Length(Result) downto 1 do
if Pos(Result[I], '\/:*?"<>|') > 0 then
Delete(Result, I, 1);
end;
function IsUpgrade: Boolean;
begin
Result := CompareText(RemoveBackslash(WizardForm.PrevAppDir), RemoveBackslash(WizardDirValue)) = 0;
end;
function NextButtonClick(CurPageID: Integer): Boolean;
var
Rec: TFindRec;
begin
Result := True;
if (CurPageID = wpSelectDir) and not IsUpgrade and FindFirst(AddBackslash(WizardDirValue) + '*', Rec) then
try
repeat
if (Rec.Name <> '.') and (Rec.Name <> '..') then
begin
SuppressibleMsgBox(WizardDirValue + ' already contains files. Choose an empty or new folder.', mbError, MB_OK, IDOK);
Result := False;
Break;
end;
until not FindNext(Rec);
finally
FindClose(Rec);
end;
end;
Your users get a familiar wizard. It installs into %LOCALAPPDATA%\Programs or
C:\Program Files, depending on their choice. Running a newer installer
upgrades in place: Setup closes the app if it is running, then replaces its
files. Uninstalling from Settings → Apps removes the folder and the
shortcuts, but leaves the app's data in %APPDATA% and %LOCALAPPDATA%, as
Windows apps conventionally do.
For unattended installs, for example by an IT department, Setup takes command-line parameters:
.\my_app-1.0.0-setup.exe /VERYSILENT /SUPPRESSMSGBOXES /CURRENTUSER
Use /ALLUSERS, from an administrator terminal, to install for all users.
The simplest option is a zip file that users extract and run. There is nothing to install, so there is no Start menu entry or uninstaller either: users delete the folder when they are done.
Compress-Archive -Path build\windows\* -DestinationPath build\My-App-1.0.0.zip -Force
This zips the folder's contents, so Extract all creates a folder named
after the zip, with the .exe at its top. -Force replaces the zip from an
earlier run, and writing it into build keeps it out of your next build.
Tell your users to extract the zip before running the app. Double-clicking
the .exe inside the zip makes Windows warn that the application may depend on
other compressed files. Choosing Run anyway copies only the .exe to a
temporary folder, where it fails with a ... .dll was not found error.
Deleting the extracted folder doesn't remove the app's data, which lives in
%APPDATA% and %LOCALAPPDATA%, as for an installed app.
Code signing
flet build doesn't sign Windows apps. Microsoft Defender SmartScreen can stop
users from running an unsigned installer or app they downloaded: it shows
Windows protected your PC, names an Unknown publisher, and runs the file
only after More info → Run anyway. A self-signed certificate changes
nothing here. A certificate from a trusted certificate authority replaces
Unknown publisher with your name, but the warning keeps showing until the file
or your certificate has built up reputation through downloads. EV certificates
no longer skip this. On Windows 11 PCs where Smart App Control is on, unsigned
apps can be blocked outright. See Microsoft's
SmartScreen reputation
guide.
Certificate authorities deliver code-signing certificates on a hardware token or
through a cloud signing service, not as a file. With your certificate in the
Windows certificate store, sign the app with
signtool
after each flet build windows, which replaces build\windows, and before you
compile the installer or create the zip. In PowerShell, from your project
directory:
$signtool = (Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" | Select-Object -Last 1).FullName
if (-not $signtool) { throw "signtool.exe not found: install the Windows SDK" }
$thumbprint = "<your certificate's thumbprint>"
$unsigned = @(Get-ChildItem build\windows -Recurse -Include *.exe, *.dll, *.pyd |
Where-Object { (Get-AuthenticodeSignature $_.FullName).Status -eq "NotSigned" })
for ($i = 0; $i -lt $unsigned.Count; $i += 50) {
& $signtool sign /fd sha256 /sha1 $thumbprint /tr http://timestamp.digicert.com /td sha256 $unsigned[$i..($i + 49)].FullName
if ($LASTEXITCODE -ne 0) { throw "signtool failed with exit code $LASTEXITCODE" }
}
To have Inno Setup sign setup.exe and its uninstaller too, add
SignTool=mysign to the script's [Setup] section, and define mysign when
you compile, in the same PowerShell session:
& "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe" "/Smysign=`$q$signtool`$q sign /fd sha256 /sha1 $thumbprint /tr http://timestamp.digicert.com /td sha256 `$f" installer.iss
Inno Setup replaces $q with a quote and $f with the file to sign; the
backticks keep PowerShell from expanding them. To rehearse all of this before
you have a certificate, create a self-signed one with
New-SelfSignedCertificate -Type CodeSigningCert -Subject "CN=My App Test" -CertStoreLocation Cert:\CurrentUser\My.
In CI, where you can't plug in a token,
Artifact Signing,
Microsoft's cloud signing service, works with the same signtool command:
replace /sha1 $thumbprint with the /dlib and /dmdf options from its
signtool guide.
Building the installer in CI
GitHub-hosted Windows runners come with Inno Setup 6 installed and iscc on
PATH. In a workflow like the one under GitHub Actions,
add these steps after the step that builds the app:
- name: Build installer
if: runner.os == 'Windows'
run: iscc installer.iss
- name: Upload installer
if: runner.os == 'Windows'
uses: actions/upload-artifact@v7
with:
name: windows-installer
path: build/installer/*.exe
if-no-files-found: error
Troubleshooting
| Symptom | Cause and fix |
|---|---|
Building with plugins requires symlink support | Windows Developer Mode is off — run start ms-settings:developers, enable it (see this guide), and rebuild. |
Unable to find suitable Visual Studio toolchain | The Desktop development with C++ workload is missing — install it with the Visual Studio Installer (see Prerequisites). |
| The app window never appears, stays blank, or renders slowly | Impeller, the default renderer, misbehaves with some GPUs, drivers and virtual machines — set FLET_NO_IMPELLER=1 or build with --no-impeller to render with Skia. See Renderer. |
Setup stops with DeleteFile failed; code 5. Access is denied. while upgrading | The app is still running and Setup couldn't close it — close the app and click Try again. Setup normally closes it for you, but not when installing for the current user on Windows on ARM, where Windows' x64 emulation service holds the app's files too. |
| Windows protected your PC when users run your installer | The installer is unsigned, or signed with a certificate that hasn't earned SmartScreen reputation yet — see Code signing. |
Value of [Setup] section directive "SignTool" is invalid | The script names a sign tool that the compile command doesn't define — pass /S<name>=... to ISCC.exe, or remove the SignTool line for unsigned builds. See Code signing. |