8 Commits

Author SHA1 Message Date
gasaichandesu e98bcf407c fix(sync): ignore AddOns subfolders to make sure we do not overwrite user's addons 2026-08-07 05:15:49 +04:00
sindoring f395d2515d fix 2026-07-28 21:28:53 +04:00
sindoring 3b01afeeb2 фиксы 2026-07-28 21:25:06 +04:00
sindoring b7e6a4b393 fix 2026-07-28 21:12:21 +04:00
sindoring 33659f6c05 fix 2026-07-28 20:56:19 +04:00
sindoring bd1e464b5a деплой 2026-07-28 20:51:54 +04:00
sindoring 3d43420676 fix 2026-07-28 20:48:31 +04:00
sindoring c4576c84bb Merge pull request 'chore: New UI implementation' (#1) from feat/ui into master
Reviewed-on: #1
2026-07-28 19:44:14 +03:00
25 changed files with 496 additions and 159 deletions
+7
View File
@@ -0,0 +1,7 @@
AWS_ACCESS_KEY_ID=replace_with_access_key_id
AWS_SECRET_ACCESS_KEY=replace_with_secret_access_key
AWS_DEFAULT_REGION=ru-central1
AWS_BUCKET=warcraft-client
AWS_ENDPOINT=https://storage.yandexcloud.net
AWS_USE_PATH_STYLE_ENDPOINT=true
LAUNCHER_AUTH_KEY=replace_with_launcher_auth_key
+5
View File
@@ -49,3 +49,8 @@ app.*.map.json
# Local launcher update signing material # Local launcher update signing material
.moonwell_signing/ .moonwell_signing/
# Local production deployment credentials
.env
.env.*
!.env.example
+12
View File
@@ -4,6 +4,14 @@ MoonWell Launcher is the desktop launcher for MoonWell, a private World of
Warcraft: Wrath of the Lich King server focused on a player-friendly experience Warcraft: Wrath of the Lich King server focused on a player-friendly experience
without excessive grind and with stronger social play. without excessive grind and with stronger social play.
The Windows executable requests administrator privileges on startup so it can
synchronize game clients installed under protected directories such as
`Program Files`.
Launcher version labels are read from the packaged application metadata. Change
only the `version: X.Y.Z+N` value in `pubspec.yaml`; login, launcher status,
Windows metadata, installer packaging, and AppCast are synchronized from it.
The launcher is responsible for: The launcher is responsible for:
- logging in to a player's MoonWell account - logging in to a player's MoonWell account
@@ -108,6 +116,10 @@ Remove `-DryRun` only after reviewing the generated installer and AppCast.
See `docs/production_deploy.md` for required environment variables, production See `docs/production_deploy.md` for required environment variables, production
defaults, safety checks, and failure recovery. defaults, safety checks, and failure recovery.
Local deploy credentials are read from the ignored root `.env`; use
`.env.example` as the safe template. Process and CI environment variables
override values from the file.
## Architecture ## Architecture
The UI lives in `lib/app`. Presentation-only design-system components live in The UI lives in `lib/app`. Presentation-only design-system components live in
+8 -16
View File
@@ -1,22 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" <rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel> <channel>
<title>MoonWell Launcher</title> <title>MoonWell Launcher</title>
<description>Обновления MoonWell Launcher</description> <description>MoonWell Launcher updates</description>
<language>ru</language> <language>ru</language>
<item> <item>
<title>MoonWell Launcher 1.0.1</title> <title>MoonWell Launcher 1.0.2</title>
<description>Обновление интерфейса, синхронизации клиента и интеграции с сервером MoonWell.</description> <description>Исправлено отображение версии и добавлен запуск с правами администратора.</description>
<pubDate>Tue, 28 Jul 2026 20:33:00 +0400</pubDate> <pubDate>Tue, 28 Jul 2026 21:23:41 +04:00</pubDate>
<enclosure <enclosure url="https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe" sparkle:version="1.0.2+4" sparkle:shortVersionString="1.0.2" sparkle:os="windows" sparkle:dsaSignature="MD0CHQDFH5GJKhykmXEZJTInFPrsoIkDc9tKroeR2XvJAhxrRjugtgetJlDiAhlyOtvgIYVRmAIb2sVqLelj" length="19994970" type="application/octet-stream" />
url="https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe"
sparkle:version="1.0.1"
sparkle:shortVersionString="1.0.1"
sparkle:os="windows"
sparkle:dsaSignature="MD4CHQCdoCW5jNymVlRblQpqy8C+jw4W0rVFQX0Bk25PAh0Av2ZcFYwZFQBQlAstQXna/PsuMNWIzXF4jpDuSA=="
length="19989772"
type="application/octet-stream" />
</item> </item>
</channel> </channel>
</rss> </rss>
+12 -3
View File
@@ -9,6 +9,11 @@ WinSparkle shows its native prompt only when a newer version is available.
Missing configuration, network failures, and malformed feeds do not block the Missing configuration, network failures, and malformed feeds do not block the
launcher. Periodic WinSparkle checks are disabled. launcher. Periodic WinSparkle checks are disabled.
`MoonWell.exe` uses the Windows `requireAdministrator` execution level. Users
therefore receive a UAC prompt whenever the launcher starts. This is required
to synchronize game files installed in protected directories. The per-user
Inno Setup installer remains configured with `PrivilegesRequired=lowest`.
## Build Configuration ## Build Configuration
Pass the public feed URL at build time: Pass the public feed URL at build time:
@@ -45,8 +50,11 @@ certificate would not establish trust on player machines.
## Version and AppCast Contract ## Version and AppCast Contract
Increase the `X.Y.Z` version in `pubspec.yaml` for every launcher release. A Increase the `X.Y.Z` version in `pubspec.yaml` for every launcher release. A
build-number-only change such as `1.0.0+2` is not sufficient because WinSparkle build-number-only change such as `1.0.0+2` is not sufficient for a public
compares the Windows product version. Pass the same `X.Y.Z` value to Inno Setup: release. Flutter writes the complete `X.Y.Z+N` value into the Windows
`ProductVersion`; `sparkle:version` must match that complete value exactly.
Use `sparkle:shortVersionString` for the human-readable `X.Y.Z` value. Pass
`X.Y.Z` without the build suffix to Inno Setup:
```powershell ```powershell
ISCC.exe /DMyAppVersion=1.1.0 installer\moonwell_launcher.iss ISCC.exe /DMyAppVersion=1.1.0 installer\moonwell_launcher.iss
@@ -70,7 +78,8 @@ Use a public AppCast with absolute URLs:
<pubDate>Sun, 21 Jun 2026 12:00:00 +0400</pubDate> <pubDate>Sun, 21 Jun 2026 12:00:00 +0400</pubDate>
<enclosure <enclosure
url="https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe" url="https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe"
sparkle:version="1.1.0" sparkle:version="1.1.0+3"
sparkle:shortVersionString="1.1.0"
sparkle:os="windows" sparkle:os="windows"
sparkle:dsaSignature="SIGNATURE_FROM_SIGN_UPDATE" sparkle:dsaSignature="SIGNATURE_FROM_SIGN_UPDATE"
length="ACTUAL_INSTALLER_SIZE_IN_BYTES" length="ACTUAL_INSTALLER_SIZE_IN_BYTES"
+30 -8
View File
@@ -37,14 +37,29 @@ Install the Python dependency if required:
python -m pip install boto3 python -m pip install boto3
``` ```
The PowerShell entry point calls `tool/upload_launcher_release.py` as a regular
Python file. Keep this helper beside the deployment script; it performs the S3
upload and verifies the uploaded object's byte length.
Back up `.moonwell_signing\dsa_priv.pem` in the project secret store. Never Back up `.moonwell_signing\dsa_priv.pem` in the project secret store. Never
commit it, upload it to Object Storage, or send it through chat. Losing this key commit it, upload it to Object Storage, or send it through chat. Losing this key
prevents released launchers from accepting future updates. prevents released launchers from accepting future updates.
## Required Environment ## Local `.env`
The script reads secrets and storage configuration only from environment For local releases, copy `.env.example` to `.env` and fill in the real values.
variables: The repository ignores `.env` and every `.env.*` variant except the safe
`.env.example` template.
```powershell
Copy-Item .env.example .env
```
The deploy script loads `.env` automatically. Variables already present in the
current process or CI environment take precedence over the file. Use
`-EnvFile C:\secure\moonwell.env` to select a different local file.
The following values are supported:
| Variable | Required | Purpose | | Variable | Required | Purpose |
| --- | --- | --- | | --- | --- | --- |
@@ -56,8 +71,8 @@ variables:
| `AWS_BUCKET` | optional | Defaults to `warcraft-client` | | `AWS_BUCKET` | optional | Defaults to `warcraft-client` |
| `AWS_USE_PATH_STYLE_ENDPOINT` | optional | Defaults to `true` | | `AWS_USE_PATH_STYLE_ENDPOINT` | optional | Defaults to `true` |
Load secrets from the team secret manager into the current PowerShell process. Production CI should load these values from the team secret manager instead of
Do not put real values in a tracked `.env` file or in the script. creating `.env`. Never commit the real file or put its contents into logs.
## Prepare a Release ## Prepare a Release
@@ -67,9 +82,16 @@ Increase both parts of the version in `pubspec.yaml`:
version: 1.0.2+3 version: 1.0.2+3
``` ```
`X.Y.Z` is the WinSparkle release version. `N` is the Flutter build number. `X.Y.Z` is the public launcher version. `N` is the Flutter build number.
Every production release must have an `X.Y.Z` value greater than the version Flutter places the complete `X.Y.Z+N` value in the Windows `ProductVersion`,
currently published in AppCast. so the script writes that same complete value to `sparkle:version` and writes
`X.Y.Z` to `sparkle:shortVersionString`. Every production release must have an
`X.Y.Z` value greater than the release currently published in AppCast.
The UI reads `X.Y.Z` from the packaged application metadata through
`package_info_plus`. Version labels on the login and launcher screens therefore
update automatically after changing `pubspec.yaml`; do not add separate
hardcoded version strings.
Run a dry run first: Run a dry run first:
+6 -6
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/launcher_version.dart';
import 'package:moonwell_launcher/app/design_system/mw_shells.dart'; import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart'; import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
@@ -47,6 +48,7 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent; final accent = MoonWellDesignTokens.of(context).accent;
final registering = widget.mode == MwAuthenticationMode.registration; final registering = widget.mode == MwAuthenticationMode.registration;
final launcherVersion = LauncherVersionScope.of(context);
return AutofillGroup( return AutofillGroup(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -259,10 +261,10 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
), ),
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
const Center( Center(
child: Text( child: Text(
'MOONWELL · V 1.0.0 · PLAY.MOON-WELL.ONLINE', 'MOONWELL · V $launcherVersion · PLAY.MOON-WELL.ONLINE',
style: TextStyle( style: const TextStyle(
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
color: Color(0x52ECE4D0), color: Color(0x52ECE4D0),
fontSize: 8, fontSize: 8,
@@ -311,9 +313,7 @@ class _Tab extends StatelessWidget {
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(color: selected ? accent : Colors.transparent),
color: selected ? accent : const Color(0x247FB8D4),
),
), ),
), ),
child: Text( child: Text(
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/launcher_version.dart';
import 'package:moonwell_launcher/app/design_system/mw_primitives.dart'; import 'package:moonwell_launcher/app/design_system/mw_primitives.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart'; import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
@@ -718,37 +719,40 @@ class MwLauncherStatusBar extends StatelessWidget {
final String? eta; final String? eta;
final String? buildHash; final String? buildHash;
@override @override
Widget build(BuildContext context) => Container( Widget build(BuildContext context) {
padding: const EdgeInsets.symmetric(horizontal: 32), final launcherVersion = LauncherVersionScope.of(context);
decoration: const BoxDecoration( return Container(
color: Color(0xD904060E), padding: const EdgeInsets.symmetric(horizontal: 32),
border: Border(top: BorderSide(color: _border)), decoration: const BoxDecoration(
), color: Color(0xD904060E),
child: DefaultTextStyle( border: Border(top: BorderSide(color: _border)),
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 8,
letterSpacing: .8,
), ),
child: Row( child: DefaultTextStyle(
children: [ style: const TextStyle(
Expanded( fontFamily: 'JetBrains Mono',
child: Text(status, maxLines: 1, overflow: TextOverflow.ellipsis), color: _muted,
), fontSize: 8,
if (fileCount != null) Text(fileCount!), letterSpacing: .8,
if (speed != null) ...[const SizedBox(width: 24), Text(speed!)], ),
if (eta != null) ...[const SizedBox(width: 24), Text(eta!)], child: Row(
if (buildHash != null) ...[ children: [
Expanded(
child: Text(status, maxLines: 1, overflow: TextOverflow.ellipsis),
),
if (fileCount != null) Text(fileCount!),
if (speed != null) ...[const SizedBox(width: 24), Text(speed!)],
if (eta != null) ...[const SizedBox(width: 24), Text(eta!)],
if (buildHash != null) ...[
const SizedBox(width: 24),
Text(buildHash!),
],
const SizedBox(width: 24), const SizedBox(width: 24),
Text(buildHash!), Text('Лаунчер v$launcherVersion'),
], ],
const SizedBox(width: 24), ),
const Text('Лаунчер v1.0.0'),
],
), ),
), );
); }
} }
class MwAccountAffordance extends StatelessWidget { class MwAccountAffordance extends StatelessWidget {
+10 -8
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/launcher_version.dart';
import 'package:moonwell_launcher/app/design_system/mw_launcher_components.dart'; import 'package:moonwell_launcher/app/design_system/mw_launcher_components.dart';
import 'package:moonwell_launcher/app/design_system/mw_window_chrome.dart'; import 'package:moonwell_launcher/app/design_system/mw_window_chrome.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart'; import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
@@ -302,21 +303,22 @@ class _LoginStatusBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final launcherVersion = LauncherVersionScope.of(context);
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 32), padding: const EdgeInsets.symmetric(horizontal: 32),
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: Color(0xD904060E), color: Color(0xD904060E),
border: Border(top: BorderSide(color: Color(0x247FB8D4))), border: Border(top: BorderSide(color: Color(0x247FB8D4))),
), ),
child: const Row( child: Row(
children: [ children: [
Text('Версия лаунчера 1.0.0'), Text('Версия лаунчера $launcherVersion'),
SizedBox(width: 24), const SizedBox(width: 24),
Text('·'), const Text('·'),
SizedBox(width: 24), const SizedBox(width: 24),
Text('Соединение защищено TLS'), const Text('Соединение защищено TLS'),
Spacer(), const Spacer(),
Text('© 2026 MoonWell'), const Text('© 2026 MoonWell'),
], ],
), ),
); );
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/widgets.dart';
class LauncherVersionScope extends InheritedWidget {
const LauncherVersionScope({
super.key,
required this.version,
required super.child,
});
final String version;
static String of(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<LauncherVersionScope>()
?.version ??
'dev';
}
@override
bool updateShouldNotify(LauncherVersionScope oldWidget) {
return version != oldWidget.version;
}
}
+14 -8
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/design_system/mw_shells.dart'; import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
import 'package:moonwell_launcher/app/home_screen/home_screen.dart'; import 'package:moonwell_launcher/app/home_screen/home_screen.dart';
import 'package:moonwell_launcher/app/launcher_version.dart';
import 'package:moonwell_launcher/app/login_screen/login_screen.dart'; import 'package:moonwell_launcher/app/login_screen/login_screen.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart'; import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
import 'package:moonwell_launcher/features/launcher/application/restore_launcher_session_use_case.dart'; import 'package:moonwell_launcher/features/launcher/application/restore_launcher_session_use_case.dart';
@@ -9,7 +10,9 @@ import 'package:moonwell_launcher/features/preferences/domain/repositories/prefe
import 'package:moonwell_launcher/service_container.dart'; import 'package:moonwell_launcher/service_container.dart';
class MoonWellApp extends StatefulWidget { class MoonWellApp extends StatefulWidget {
const MoonWellApp({super.key}); const MoonWellApp({super.key, required this.launcherVersion});
final String launcherVersion;
@override @override
State<MoonWellApp> createState() => _MoonWellAppState(); State<MoonWellApp> createState() => _MoonWellAppState();
@@ -35,13 +38,16 @@ class _MoonWellAppState extends State<MoonWellApp> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedBuilder( return AnimatedBuilder(
animation: _themeController, animation: _themeController,
builder: (context, _) => LauncherThemeScope( builder: (context, _) => LauncherVersionScope(
controller: _themeController, version: widget.launcherVersion,
child: MaterialApp( child: LauncherThemeScope(
title: 'MoonWell', controller: _themeController,
debugShowCheckedModeBanner: false, child: MaterialApp(
home: const _LauncherBootstrapScreen(), title: 'MoonWell',
theme: MoonWellTheme.create(_themeController.variant), debugShowCheckedModeBanner: false,
home: const _LauncherBootstrapScreen(),
theme: MoonWellTheme.create(_themeController.variant),
),
), ),
), ),
); );
+1
View File
@@ -11,6 +11,7 @@ final class Config {
static const launcherMetadataDirectoryName = '.moonwell_launcher'; static const launcherMetadataDirectoryName = '.moonwell_launcher';
static const hashCacheFileName = 'hash_cache.json'; static const hashCacheFileName = 'hash_cache.json';
static const ignoredVerificationDirectories = <String>{ static const ignoredVerificationDirectories = <String>{
'addons',
'cache', 'cache',
'errors', 'errors',
'logs', 'logs',
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/config.dart';
import 'package:moonwell_launcher/core/use_case.dart'; import 'package:moonwell_launcher/core/use_case.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart'; import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart'; import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
@@ -95,6 +96,7 @@ class ClientSyncUseCase
final localSnapshot = await _installationService.scanInstallation( final localSnapshot = await _installationService.scanInstallation(
input.request.installationDir, input.request.installationDir,
isCancelled: input.isCancelled, isCancelled: input.isCancelled,
manifest: manifest,
onProgress: (progress, currentPath, processedFiles, totalFiles) { onProgress: (progress, currentPath, processedFiles, totalFiles) {
_emit( _emit(
controller, controller,
@@ -185,6 +187,7 @@ class ClientSyncUseCase
final verifiedSnapshot = await _installationService.scanInstallation( final verifiedSnapshot = await _installationService.scanInstallation(
input.request.installationDir, input.request.installationDir,
isCancelled: input.isCancelled, isCancelled: input.isCancelled,
manifest: manifest,
onProgress: (progress, currentPath, processedFiles, totalFiles) { onProgress: (progress, currentPath, processedFiles, totalFiles) {
_emit( _emit(
controller, controller,
@@ -279,7 +282,13 @@ class ClientSyncUseCase
final remoteFiles = manifest.filesByPath; final remoteFiles = manifest.filesByPath;
return snapshot.files return snapshot.files
.where((localFile) => !remoteFiles.containsKey(localFile.path)) .where((localFile) {
if (remoteFiles.containsKey(localFile.path)) {
return false;
}
return !_isIgnored(localFile.path);
})
.map((file) => file.path) .map((file) => file.path)
.toList(); .toList();
} }
@@ -454,14 +463,21 @@ class ClientSyncUseCase
final mismatchedFiles = manifest.files.where((remoteFile) { final mismatchedFiles = manifest.files.where((remoteFile) {
final localFile = localFilesByPath[remoteFile.path]; final localFile = localFilesByPath[remoteFile.path];
return localFile == null || final isMismatched =
localFile == null ||
localFile.sha256 != remoteFile.sha256 || localFile.sha256 != remoteFile.sha256 ||
localFile.size != remoteFile.size; localFile.size != remoteFile.size;
return isMismatched;
}).toList(); }).toList();
final extraFiles = snapshot.files final extraFiles = snapshot.files.where((localFile) {
.where((localFile) => !manifest.filesByPath.containsKey(localFile.path)) if (manifest.filesByPath.containsKey(localFile.path)) {
.toList(); return false;
}
return !_isIgnored(localFile.path);
}).toList();
if (snapshot.buildHash != remoteBuildHash || if (snapshot.buildHash != remoteBuildHash ||
mismatchedFiles.isNotEmpty || mismatchedFiles.isNotEmpty ||
@@ -492,6 +508,15 @@ class ClientSyncUseCase
} }
} }
/// Файлы, которые не будут удалены во время проверки файлов. Например, пользовательские
/// аддоны, скриншоты и т.д.
bool _isIgnored(String filePath) {
final normalizedPath = filePath.replaceAll('\\', '/').toLowerCase();
return Config.ignoredVerificationDirectories.any(
(pattern) => normalizedPath.contains(pattern.toLowerCase()),
);
}
Future<void> _verifyDownloadedFile({ Future<void> _verifyDownloadedFile({
required String installationDir, required String installationDir,
required ClientManifestFile file, required ClientManifestFile file,
@@ -8,10 +8,11 @@ import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/config.dart'; import 'package:moonwell_launcher/config.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart'; import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart'; import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
typedef InstallationScanProgressCallback = typedef InstallationScanProgressCallback =
@@ -33,6 +34,7 @@ final class GameProcessHandle {
class GameInstallationService { class GameInstallationService {
Future<ClientInstallationSnapshot> scanInstallation( Future<ClientInstallationSnapshot> scanInstallation(
String installationDir, { String installationDir, {
required ClientManifest manifest,
InstallationScanProgressCallback? onProgress, InstallationScanProgressCallback? onProgress,
FutureOr<bool> Function()? isCancelled, FutureOr<bool> Function()? isCancelled,
}) async { }) async {
@@ -132,6 +134,7 @@ class GameInstallationService {
'hashCachePath': hashCachePath, 'hashCachePath': hashCachePath,
'cachedHashes': cachedHashes, 'cachedHashes': cachedHashes,
'ignoredDirectories': Config.ignoredVerificationDirectories.toList(), 'ignoredDirectories': Config.ignoredVerificationDirectories.toList(),
'serverFiles': manifest.files.map((f) => f.path).toList(),
}, },
onError: errorPort.sendPort, onError: errorPort.sendPort,
onExit: exitPort.sendPort, onExit: exitPort.sendPort,
@@ -343,6 +346,9 @@ Future<void> _scanInstallationIsolateMain(Map<String, Object?> message) async {
.whereType<String>() .whereType<String>()
.map((entry) => entry.toLowerCase()) .map((entry) => entry.toLowerCase())
.toSet(); .toSet();
final serverFiles = (message['serverFiles'] as List<Object?>)
.whereType<String>()
.toSet();
final rootDirectory = Directory(installationDir); final rootDirectory = Directory(installationDir);
if (!await rootDirectory.exists()) { if (!await rootDirectory.exists()) {
@@ -356,6 +362,7 @@ Future<void> _scanInstallationIsolateMain(Map<String, Object?> message) async {
rootDirectory.path, rootDirectory.path,
pendingFiles, pendingFiles,
ignoredDirectories, ignoredDirectories,
serverFiles,
); );
pendingFiles.sort( pendingFiles.sort(
@@ -424,41 +431,21 @@ Future<void> _collectFilesInIsolate(
String rootPath, String rootPath,
List<_SerializablePendingFile> pendingFiles, List<_SerializablePendingFile> pendingFiles,
Set<String> ignoredDirectories, Set<String> ignoredDirectories,
Set<String> serverFiles,
) async { ) async {
await for (final entity in directory.list(followLinks: false)) { for (final path in serverFiles) {
final relativePath = normalizeClientPath( final file = File('$rootPath/$path');
p.relative(entity.path, from: rootPath),
);
if (relativePath.isEmpty || relativePath == '.') { if (!await file.exists()) {
continue; continue;
} }
final segments = p.posix.split(relativePath); final stat = await file.stat();
if (segments.isNotEmpty &&
ignoredDirectories.contains(segments.first.toLowerCase())) {
continue;
}
if (entity is Directory) {
await _collectFilesInIsolate(
entity,
rootPath,
pendingFiles,
ignoredDirectories,
);
continue;
}
if (entity is! File) {
continue;
}
final stat = await entity.stat();
pendingFiles.add( pendingFiles.add(
_SerializablePendingFile( _SerializablePendingFile(
path: entity.path, path: file.path,
relativePath: relativePath, relativePath: normalizeClientPath(p.relative(path)),
size: stat.size, size: stat.size,
modifiedMs: stat.modified.millisecondsSinceEpoch, modifiedMs: stat.modified.millisecondsSinceEpoch,
), ),
+12 -1
View File
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/mw_app.dart'; import 'package:moonwell_launcher/app/mw_app.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_update_service.dart'; import 'package:moonwell_launcher/features/launcher/data/launcher_update_service.dart';
import 'package:moonwell_launcher/service_container.dart'; import 'package:moonwell_launcher/service_container.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
Future<void> main() async { Future<void> main() async {
@@ -13,6 +14,7 @@ Future<void> main() async {
await configureDependencies(env: 'flutter'); await configureDependencies(env: 'flutter');
final launcherUpdateService = getIt<LauncherUpdateService>(); final launcherUpdateService = getIt<LauncherUpdateService>();
final launcherVersion = await _readLauncherVersion();
const windowOptions = WindowOptions( const windowOptions = WindowOptions(
size: Size(1280, 720), size: Size(1280, 720),
@@ -31,5 +33,14 @@ Future<void> main() async {
}), }),
); );
runApp(const MoonWellApp()); runApp(MoonWellApp(launcherVersion: launcherVersion));
}
Future<String> _readLauncherVersion() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
return packageInfo.version;
} catch (_) {
return 'dev';
}
} }
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/package_info_plus-9.0.1/
@@ -8,6 +8,7 @@ import Foundation
import auto_updater_macos import auto_updater_macos
import file_picker import file_picker
import flutter_secure_storage_darwin import flutter_secure_storage_darwin
import package_info_plus
import screen_retriever_macos import screen_retriever_macos
import shared_preferences_foundation import shared_preferences_foundation
import url_launcher_macos import url_launcher_macos
@@ -17,6 +18,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AutoUpdaterMacosPlugin.register(with: registry.registrar(forPlugin: "AutoUpdaterMacosPlugin")) AutoUpdaterMacosPlugin.register(with: registry.registrar(forPlugin: "AutoUpdaterMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
+24
View File
@@ -424,6 +424,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.4.0" version: "4.4.0"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_multi_server: http_multi_server:
dependency: transitive dependency: transitive
description: description:
@@ -592,6 +600,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
url: "https://pub.dev"
source: hosted
version: "9.0.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path: path:
dependency: "direct main" dependency: "direct main"
description: description:
+2 -1
View File
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.1+2 version: 1.0.2+4
environment: environment:
sdk: ">=3.10.0" sdk: ">=3.10.0"
@@ -47,6 +47,7 @@ dependencies:
window_manager: ^0.5.1 window_manager: ^0.5.1
auto_updater: 1.0.0 auto_updater: 1.0.0
url_launcher: ^6.3.2 url_launcher: ^6.3.2
package_info_plus: ^9.0.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:moonwell_launcher/app/design_system/mw_authentication.dart'; import 'package:moonwell_launcher/app/design_system/mw_authentication.dart';
import 'package:moonwell_launcher/app/launcher_version.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart'; import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
void main() { void main() {
@@ -81,4 +82,62 @@ void main() {
expect(requestedMode, MwAuthenticationMode.registration); expect(requestedMode, MwAuthenticationMode.registration);
}); });
testWidgets('inactive authentication tab has no divider', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
home: Scaffold(
body: MwAuthenticationForm(
usernameController: TextEditingController(),
passwordController: TextEditingController(),
onSubmit: () {},
),
),
),
);
final tabBorders = tester
.widgetList<Container>(
find.descendant(
of: find.byType(MwAuthenticationForm),
matching: find.byType(Container),
),
)
.map((container) => container.decoration)
.whereType<BoxDecoration>()
.map((decoration) => decoration.border)
.whereType<Border>()
.where((border) => border.bottom.style != BorderStyle.none)
.map((border) => border.bottom.color)
.toList();
expect(tabBorders, hasLength(2));
expect(
tabBorders.where((color) => color == Colors.transparent),
hasLength(1),
);
});
testWidgets('authentication footer uses the packaged launcher version', (
tester,
) async {
await tester.pumpWidget(
LauncherVersionScope(
version: '9.8.7',
child: MaterialApp(
theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
home: Scaffold(
body: MwAuthenticationForm(
usernameController: TextEditingController(),
passwordController: TextEditingController(),
onSubmit: () {},
),
),
),
),
);
expect(find.textContaining('V 9.8.7'), findsOneWidget);
});
} }
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:moonwell_launcher/app/design_system/mw_launcher_components.dart'; import 'package:moonwell_launcher/app/design_system/mw_launcher_components.dart';
import 'package:moonwell_launcher/app/launcher_version.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart'; import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
void main() { void main() {
@@ -137,6 +138,25 @@ void main() {
expect(find.text('Проверить файлы'), findsOneWidget); expect(find.text('Проверить файлы'), findsOneWidget);
expect(find.text('Выйти'), findsNothing); expect(find.text('Выйти'), findsNothing);
}); });
testWidgets('status bar uses the packaged launcher version', (tester) async {
await tester.pumpWidget(
LauncherVersionScope(
version: '9.8.7',
child: MaterialApp(
theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
home: const Scaffold(
body: SizedBox(
height: 26,
child: MwLauncherStatusBar(status: 'Ready'),
),
),
),
),
);
expect(find.textContaining('v9.8.7'), findsOneWidget);
});
} }
void _noop() {} void _noop() {}
+12 -7
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:moonwell_launcher/app/design_system/mw_shells.dart'; import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
import 'package:moonwell_launcher/app/launcher_version.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart'; import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
void main() { void main() {
@@ -24,13 +25,16 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
theme: MoonWellTheme.create(MoonWellThemeVariant.forest), theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
home: MwLoginShell( home: LauncherVersionScope(
form: const SizedBox(), version: '9.8.7',
onDrag: _noop, child: MwLoginShell(
onDoubleTap: _noop, form: const SizedBox(),
onMinimize: _noop, onDrag: _noop,
onMaximizeRestore: _noop, onDoubleTap: _noop,
onClose: _noop, onMinimize: _noop,
onMaximizeRestore: _noop,
onClose: _noop,
),
), ),
), ),
); );
@@ -38,6 +42,7 @@ void main() {
final image = tester.widget<Image>(find.byType(Image)); final image = tester.widget<Image>(find.byType(Image));
expect((image.image as AssetImage).assetName, 'assets/login_key_art.png'); expect((image.image as AssetImage).assetName, 'assets/login_key_art.png');
expect(find.textContaining('9.8.7'), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
+85 -41
View File
@@ -14,6 +14,9 @@ Fails unless the X.Y.Z part of pubspec.yaml matches this value.
.PARAMETER ReleaseNotes .PARAMETER ReleaseNotes
Text written to the AppCast item description. Text written to the AppCast item description.
.PARAMETER EnvFile
Local dotenv file used to fill environment variables that are not already set.
.PARAMETER DryRun .PARAMETER DryRun
Builds, signs, and verifies local artifacts without changing production. Builds, signs, and verifies local artifacts without changing production.
@@ -42,6 +45,7 @@ param(
[string]$S3Endpoint, [string]$S3Endpoint,
[string]$S3Bucket, [string]$S3Bucket,
[string]$S3Key = 'moonwell_launcher_setup.exe', [string]$S3Key = 'moonwell_launcher_setup.exe',
[string]$EnvFile = '.env',
[string]$PrivateKeyPath = '.moonwell_signing\dsa_priv.pem', [string]$PrivateKeyPath = '.moonwell_signing\dsa_priv.pem',
[string]$PublicKeyPath = 'windows\runner\resources\dsa_pub.pem', [string]$PublicKeyPath = 'windows\runner\resources\dsa_pub.pem',
[string]$FlutterCommand = 'flutter', [string]$FlutterCommand = 'flutter',
@@ -101,6 +105,64 @@ function Get-FileSha256 {
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
} }
function Import-DotEnv {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
return
}
$allowedNames = @(
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_DEFAULT_REGION',
'AWS_BUCKET',
'AWS_ENDPOINT',
'AWS_USE_PATH_STYLE_ENDPOINT',
'LAUNCHER_AUTH_KEY'
)
foreach ($line in Get-Content -LiteralPath $Path) {
$trimmed = $line.Trim()
if (
[string]::IsNullOrWhiteSpace($trimmed) -or
$trimmed.StartsWith('#')
) {
continue
}
$separator = $trimmed.IndexOf('=')
if ($separator -le 0) {
throw "Invalid dotenv line in ${Path}: $line"
}
$name = $trimmed.Substring(0, $separator).Trim()
if ($allowedNames -notcontains $name) {
continue
}
$value = $trimmed.Substring($separator + 1).Trim()
if (
$value.Length -ge 2 -and
(
($value.StartsWith('"') -and $value.EndsWith('"')) -or
($value.StartsWith("'") -and $value.EndsWith("'"))
)
) {
$value = $value.Substring(1, $value.Length - 2)
}
$existing = [Environment]::GetEnvironmentVariable($name)
if ([string]::IsNullOrWhiteSpace($existing)) {
[Environment]::SetEnvironmentVariable(
$name,
$value,
[EnvironmentVariableTarget]::Process
)
}
}
}
function Get-AppcastVersion { function Get-AppcastVersion {
param([Parameter(Mandatory = $true)][string]$Path) param([Parameter(Mandatory = $true)][string]$Path)
@@ -117,6 +179,7 @@ function Write-Appcast {
param( param(
[Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$Version, [Parameter(Mandatory = $true)][string]$Version,
[Parameter(Mandatory = $true)][string]$BuildVersion,
[Parameter(Mandatory = $true)][string]$Notes, [Parameter(Mandatory = $true)][string]$Notes,
[Parameter(Mandatory = $true)][string]$InstallerUrl, [Parameter(Mandatory = $true)][string]$InstallerUrl,
[Parameter(Mandatory = $true)][string]$Signature, [Parameter(Mandatory = $true)][string]$Signature,
@@ -167,7 +230,7 @@ function Write-Appcast {
'sparkle', 'sparkle',
'version', 'version',
$script:SparkleNamespace, $script:SparkleNamespace,
$Version $BuildVersion
) )
$writer.WriteAttributeString( $writer.WriteAttributeString(
'sparkle', 'sparkle',
@@ -282,6 +345,14 @@ $previousLocation = Get-Location
try { try {
Set-Location $repositoryRoot Set-Location $repositoryRoot
$resolvedEnvFile = if ([IO.Path]::IsPathRooted($EnvFile)) {
$EnvFile
}
else {
Join-Path $repositoryRoot $EnvFile
}
Import-DotEnv -Path $resolvedEnvFile
if ([string]::IsNullOrWhiteSpace($S3Endpoint)) { if ([string]::IsNullOrWhiteSpace($S3Endpoint)) {
$S3Endpoint = $env:AWS_ENDPOINT $S3Endpoint = $env:AWS_ENDPOINT
} }
@@ -399,9 +470,10 @@ try {
-UseBasicParsing ` -UseBasicParsing `
-OutFile $publishedPath -OutFile $publishedPath
$publishedVersion = Get-AppcastVersion -Path $publishedPath $publishedVersion = Get-AppcastVersion -Path $publishedPath
$publishedReleaseVersion = $publishedVersion.Split('+')[0]
if ( if (
-not [string]::IsNullOrWhiteSpace($publishedVersion) -and -not [string]::IsNullOrWhiteSpace($publishedVersion) -and
[version]$version -le [version]$publishedVersion [version]$version -le [version]$publishedReleaseVersion
) { ) {
throw ( throw (
"Version $version is not newer than published version " + "Version $version is not newer than published version " +
@@ -503,6 +575,7 @@ try {
'/' + '/' +
$S3Key $S3Key
) )
$appcastBuildVersion = "$version+$buildNumber"
$appcastPath = if ($DryRun) { $appcastPath = if ($DryRun) {
Join-Path $temporaryDirectory 'appcast.xml' Join-Path $temporaryDirectory 'appcast.xml'
} }
@@ -513,6 +586,7 @@ try {
Write-Appcast ` Write-Appcast `
-Path $appcastPath ` -Path $appcastPath `
-Version $version ` -Version $version `
-BuildVersion $appcastBuildVersion `
-Notes $ReleaseNotes ` -Notes $ReleaseNotes `
-InstallerUrl $installerUrl ` -InstallerUrl $installerUrl `
-Signature $signature ` -Signature $signature `
@@ -522,7 +596,7 @@ try {
-LiteralPath $appcastPath ` -LiteralPath $appcastPath `
-Raw ` -Raw `
-Encoding UTF8 -Encoding UTF8
if ((Get-AppcastVersion -Path $appcastPath) -ne $version) { if ((Get-AppcastVersion -Path $appcastPath) -ne $appcastBuildVersion) {
throw 'The generated AppCast contains the wrong version.' throw 'The generated AppCast contains the wrong version.'
} }
@@ -543,45 +617,15 @@ try {
$env:MOONWELL_RELEASE_KEY = $S3Key $env:MOONWELL_RELEASE_KEY = $S3Key
$env:MOONWELL_RELEASE_VERSION = $version $env:MOONWELL_RELEASE_VERSION = $version
$env:MOONWELL_RELEASE_SHA256 = $installerSha256 $env:MOONWELL_RELEASE_SHA256 = $installerSha256
$uploadScript = @' $uploadHelper = Join-Path `
import os $repositoryRoot `
import boto3 'tool\upload_launcher_release.py'
from botocore.config import Config if (-not (Test-Path -LiteralPath $uploadHelper -PathType Leaf)) {
throw "S3 upload helper was not found: $uploadHelper"
}
path_style = os.getenv("AWS_USE_PATH_STYLE_ENDPOINT", "true").lower()
addressing_style = "path" if path_style in ("1", "true", "yes") else "virtual"
s3 = boto3.client(
"s3",
endpoint_url=os.environ["MOONWELL_RELEASE_ENDPOINT"],
region_name=os.getenv("AWS_DEFAULT_REGION", "ru-central1"),
config=Config(s3={"addressing_style": addressing_style}),
)
s3.upload_file(
os.environ["MOONWELL_RELEASE_FILE"],
os.environ["MOONWELL_RELEASE_BUCKET"],
os.environ["MOONWELL_RELEASE_KEY"],
ExtraArgs={
"ACL": "public-read",
"ContentType": "application/x-msdownload",
"CacheControl": "no-cache, max-age=0",
"Metadata": {
"launcher-version": os.environ["MOONWELL_RELEASE_VERSION"],
"sha256": os.environ["MOONWELL_RELEASE_SHA256"],
},
},
)
response = s3.head_object(
Bucket=os.environ["MOONWELL_RELEASE_BUCKET"],
Key=os.environ["MOONWELL_RELEASE_KEY"],
)
print("S3_VERSION_ID=" + str(response.get("VersionId", "")))
print("S3_CONTENT_LENGTH=" + str(response["ContentLength"]))
'@
try { try {
Invoke-RequiredCommand -Command $python -Arguments @( Invoke-RequiredCommand -Command $python -Arguments @($uploadHelper)
'-c',
$uploadScript
)
} }
finally { finally {
Remove-Item Env:MOONWELL_RELEASE_FILE -ErrorAction SilentlyContinue Remove-Item Env:MOONWELL_RELEASE_FILE -ErrorAction SilentlyContinue
@@ -651,7 +695,7 @@ print("S3_CONTENT_LENGTH=" + str(response["ContentLength"]))
$publishedEnclosure.GetAttribute( $publishedEnclosure.GetAttribute(
'version', 'version',
$script:SparkleNamespace $script:SparkleNamespace
) -ne $version -or ) -ne $appcastBuildVersion -or
[long]$publishedEnclosure.length -ne $installer.Length -or [long]$publishedEnclosure.length -ne $installer.Length -or
$publishedEnclosure.url -ne $installerUrl $publishedEnclosure.url -ne $installerUrl
) { ) {
+70
View File
@@ -0,0 +1,70 @@
"""Upload a MoonWell Launcher installer to S3-compatible storage.
Release metadata is supplied by ``deploy_launcher.ps1`` through environment
variables. A real Python file avoids Windows PowerShell quoting problems that
occur when a multiline program is passed to ``python -c``.
"""
from __future__ import annotations
import os
from pathlib import Path
import boto3
from botocore.config import Config
def required_environment_value(name: str) -> str:
value = os.environ.get(name, "").strip()
if not value:
raise RuntimeError(f"Required environment variable is missing: {name}")
return value
release_file = Path(required_environment_value("MOONWELL_RELEASE_FILE"))
endpoint = required_environment_value("MOONWELL_RELEASE_ENDPOINT")
bucket = required_environment_value("MOONWELL_RELEASE_BUCKET")
object_key = required_environment_value("MOONWELL_RELEASE_KEY")
version = required_environment_value("MOONWELL_RELEASE_VERSION")
sha256 = required_environment_value("MOONWELL_RELEASE_SHA256")
if not release_file.is_file():
raise FileNotFoundError(f"Installer does not exist: {release_file}")
path_style = os.getenv("AWS_USE_PATH_STYLE_ENDPOINT", "true").lower()
addressing_style = (
"path" if path_style in ("1", "true", "yes") else "virtual"
)
s3 = boto3.client(
"s3",
endpoint_url=endpoint,
region_name=os.getenv("AWS_DEFAULT_REGION", "ru-central1"),
config=Config(s3={"addressing_style": addressing_style}),
)
s3.upload_file(
str(release_file),
bucket,
object_key,
ExtraArgs={
"ACL": "public-read",
"ContentType": "application/x-msdownload",
"CacheControl": "no-cache, max-age=0",
"Metadata": {
"launcher-version": version,
"sha256": sha256,
},
},
)
response = s3.head_object(Bucket=bucket, Key=object_key)
local_size = release_file.stat().st_size
remote_size = int(response["ContentLength"])
if remote_size != local_size:
raise RuntimeError(
f"Uploaded object size mismatch: local={local_size}, "
f"remote={remote_size}"
)
print("S3_VERSION_ID=" + str(response.get("VersionId", "")))
print("S3_CONTENT_LENGTH=" + str(remote_size))
+5
View File
@@ -35,6 +35,11 @@ target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX")
target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app)
target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib")
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
target_link_options(
${BINARY_NAME}
PRIVATE
"/MANIFESTUAC:level='requireAdministrator' uiAccess='false'"
)
# Run the Flutter tool portions of the build. This must not be removed. # Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble) add_dependencies(${BINARY_NAME} flutter_assemble)