diff --git a/example/default_example/pubspec.yaml b/example/default_example/pubspec.yaml index 5e87c2c9f0..72d8fd5cf5 100644 --- a/example/default_example/pubspec.yaml +++ b/example/default_example/pubspec.yaml @@ -38,6 +38,9 @@ flutter_launcher_icons: macos: generate: true image_path: "assets/images/icon-1024x1024.png" + linux: + generate: true + image_path: "assets/images/icon-logo.png" dev_dependencies: flutter_test: @@ -45,3 +48,5 @@ dev_dependencies: flutter: uses-material-design: true + assets: + - assets/images/icon-logo.png \ No newline at end of file diff --git a/lib/abs/icon_generator.dart b/lib/abs/icon_generator.dart index 896da5c6f3..381fc2f47f 100644 --- a/lib/abs/icon_generator.dart +++ b/lib/abs/icon_generator.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter_launcher_icons/config/config.dart'; +import 'package:flutter_launcher_icons/config/linux_config.dart'; import 'package:flutter_launcher_icons/config/macos_config.dart'; import 'package:flutter_launcher_icons/config/web_config.dart'; import 'package:flutter_launcher_icons/config/windows_config.dart'; @@ -64,6 +65,9 @@ class IconGeneratorContext { /// Shortcut for `config.macOSConfig` MacOSConfig? get macOSConfig => config.macOSConfig; + + /// Shortcut for `config.linuxConfig` + LinuxConfig? get linuxConfig => config.linuxConfig; } /// Generates Icon for given platforms diff --git a/lib/config/config.dart b/lib/config/config.dart index 9ac513c968..2dccc090c0 100644 --- a/lib/config/config.dart +++ b/lib/config/config.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:checked_yaml/checked_yaml.dart' as yaml; +import 'package:flutter_launcher_icons/config/linux_config.dart'; import 'package:flutter_launcher_icons/config/macos_config.dart'; import 'package:flutter_launcher_icons/config/web_config.dart'; import 'package:flutter_launcher_icons/config/windows_config.dart'; @@ -38,6 +39,7 @@ class Config { this.webConfig, this.windowsConfig, this.macOSConfig, + this.linuxConfig, }); /// Creates [Config] for given [flavor] and [prefixPath] @@ -173,6 +175,10 @@ class Config { @JsonKey(name: 'macos') final MacOSConfig? macOSConfig; + /// Linux platform config + @JsonKey(name: 'linux') + final LinuxConfig? linuxConfig; + /// Creates [Config] icons from [json] factory Config.fromJson(Map json) => _$ConfigFromJson(json); @@ -193,7 +199,8 @@ class Config { android != false || webConfig != null || windowsConfig != null || - macOSConfig != null; + macOSConfig != null || + linuxConfig != null; } /// Whether or not configuration for generating Web icons exist @@ -205,6 +212,9 @@ class Config { /// Whether or not configuration for generating MacOS icons exists bool get hasMacOSConfig => macOSConfig != null; + /// Whether or not configuration for generating Linux icons exists + bool get hasLinuxConfig => linuxConfig != null; + /// Check to see if specified Android config is a string or bool /// String - Generate new launcher icon with the string specified /// bool - override the default flutter project icon diff --git a/lib/config/config.g.dart b/lib/config/config.g.dart index 97dabf1bcd..7e49eb8caa 100644 --- a/lib/config/config.g.dart +++ b/lib/config/config.g.dart @@ -47,6 +47,8 @@ Config _$ConfigFromJson(Map json) => $checkedCreate( (v) => v == null ? null : WindowsConfig.fromJson(v as Map)), macOSConfig: $checkedConvert('macos', (v) => v == null ? null : MacOSConfig.fromJson(v as Map)), + linuxConfig: $checkedConvert('linux', + (v) => v == null ? null : LinuxConfig.fromJson(v as Map)), ); return val; }, @@ -66,7 +68,8 @@ Config _$ConfigFromJson(Map json) => $checkedCreate( 'backgroundColorIOS': 'background_color_ios', 'webConfig': 'web', 'windowsConfig': 'windows', - 'macOSConfig': 'macos' + 'macOSConfig': 'macos', + 'linuxConfig': 'linux' }, ); @@ -90,4 +93,5 @@ Map _$ConfigToJson(Config instance) => { 'web': instance.webConfig, 'windows': instance.windowsConfig, 'macos': instance.macOSConfig, + 'linux': instance.linuxConfig, }; diff --git a/lib/config/linux_config.dart b/lib/config/linux_config.dart new file mode 100644 index 0000000000..b906366ed9 --- /dev/null +++ b/lib/config/linux_config.dart @@ -0,0 +1,32 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'linux_config.g.dart'; + +/// The flutter_launcher_icons configuration set for Linux +@JsonSerializable( + anyMap: true, + checked: true, +) +class LinuxConfig { + /// Specifies whether to generate icons for Linux + final bool generate; + + /// Image path for Linux + @JsonKey(name: 'image_path') + final String? imagePath; + + /// Creates a instance of [LinuxConfig] + const LinuxConfig({ + this.generate = false, + this.imagePath, + }); + + /// Creates [LinuxConfig] from [json] + factory LinuxConfig.fromJson(Map json) => _$LinuxConfigFromJson(json); + + /// Creates [Map] from [LinuxConfig] + Map toJson() => _$LinuxConfigToJson(this); + + @override + String toString() => 'LinuxConfig: ${toJson()}'; +} diff --git a/lib/config/linux_config.g.dart b/lib/config/linux_config.g.dart new file mode 100644 index 0000000000..331df6bda3 --- /dev/null +++ b/lib/config/linux_config.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'linux_config.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +LinuxConfig _$LinuxConfigFromJson(Map json) => $checkedCreate( + 'LinuxConfig', + json, + ($checkedConvert) { + final val = LinuxConfig( + generate: $checkedConvert('generate', (v) => v as bool? ?? false), + imagePath: $checkedConvert('image_path', (v) => v as String?), + ); + return val; + }, + fieldKeyMap: const {'imagePath': 'image_path'}, + ); + +Map _$LinuxConfigToJson(LinuxConfig instance) => + { + 'generate': instance.generate, + 'image_path': instance.imagePath, + }; diff --git a/lib/constants.dart b/lib/constants.dart index 6ef7d01c6b..b841b30a9e 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -84,6 +84,14 @@ final macOSIconsDirPath = /// Relative path to macos contents.json final macOSContentsFilePath = path.join(macOSIconsDirPath, 'Contents.json'); +// Linux + +/// Relative path to linux directory +String linuxDirPath = path.join('linux'); + +/// Relative path to linux my_application.cc file +String linuxMyApplicationFile = path.join(linuxDirPath, 'runner', 'my_application.cc'); + const String errorMissingImagePath = 'Missing "image_path" or "image_path_android" + "image_path_ios" within configuration'; const String errorMissingPlatform = diff --git a/lib/linux/linux_icon_generator.dart b/lib/linux/linux_icon_generator.dart new file mode 100644 index 0000000000..212e615908 --- /dev/null +++ b/lib/linux/linux_icon_generator.dart @@ -0,0 +1,295 @@ +import 'dart:io'; + +import 'package:flutter_launcher_icons/abs/icon_generator.dart'; +import 'package:flutter_launcher_icons/constants.dart' as constants; +import 'package:flutter_launcher_icons/custom_exceptions.dart'; +import 'package:flutter_launcher_icons/utils.dart' as utils; +import 'package:path/path.dart' as path; +import 'package:yaml/yaml.dart'; + +/// A Implementation of [IconGenerator] for Linux +class LinuxIconGenerator extends IconGenerator { + /// Creates a instance of [LinuxIconGenerator] + LinuxIconGenerator(IconGeneratorContext context) : super(context, 'Linux'); + + @override + Future createIcons() async { + final iconPath = + context.linuxConfig!.imagePath ?? context.config.imagePath!; + + context.logger.verbose('Using Linux icon at $iconPath...'); + + // Validate that the icon path starts with assets/ (since it needs to be runtime accessible) + if (!iconPath.startsWith('assets/')) { + context.logger.error( + 'Linux icon path must be in assets directory (e.g., "assets/images/icon.png") to be accessible at runtime', + ); + throw Exception('Invalid Linux icon path: $iconPath'); + } + + // Validate that pubspec.yaml includes the assets + await _validatePubspecAssets(iconPath); + + // Update my_application.cc file with the icon path + await _updateMyApplicationFile(iconPath); + } + + @override + bool validateRequirements() { + context.logger.verbose('Validating Linux config...'); + final linuxConfig = context.linuxConfig; + if (linuxConfig == null || !linuxConfig.generate) { + context.logger.error( + 'Linux config is not provided or linux.generate is false. Skipped...', + ); + return false; + } + + if (linuxConfig.imagePath == null && context.config.imagePath == null) { + context.logger.error( + 'Invalid config. Either provide linux.image_path or image_path', + ); + return false; + } + + final iconPath = linuxConfig.imagePath ?? context.config.imagePath!; + + // Check that icon path is in assets + if (!iconPath.startsWith('assets/')) { + context.logger.error( + 'Linux icon path must be in assets directory (e.g., "assets/images/icon.png") to be accessible at runtime', + ); + return false; + } + + final entitesToCheck = [ + path.join(context.prefixPath, constants.linuxDirPath), + path.join(context.prefixPath, constants.linuxMyApplicationFile), + path.join(context.prefixPath, iconPath), + ]; + + final failedEntityPath = utils.areFSEntiesExist(entitesToCheck); + if (failedEntityPath != null) { + context.logger.error( + '$failedEntityPath this file or folder is required to generate Linux icons', + ); + return false; + } + + return true; + } + + Future _updateMyApplicationFile(String iconPath) async { + final myAppFile = + File(path.join(context.prefixPath, constants.linuxMyApplicationFile)); + + if (!myAppFile.existsSync()) { + context.logger.error( + 'my_application.cc file not found at ${constants.linuxMyApplicationFile}', + ); + throw FileNotFoundException(constants.linuxMyApplicationFile); + } + + final content = await myAppFile.readAsString(); + + // Create the new icon line + final iconLine = + 'gtk_window_set_icon_from_file(window, "$iconPath", NULL);'; + + // Check if icon configuration already exists (handles multi-line statements) + final existingIconRegex = RegExp( + r'gtk_window_set_icon_from_file\s*\(\s*[^;]*;', + multiLine: true, + dotAll: true, + ); + + final existingIconMatch = existingIconRegex.firstMatch(content); + + if (existingIconMatch != null) { + // Icon configuration exists - check if it needs updating + final existingIconStatement = existingIconMatch.group(0)!; + + // Extract the current icon path from the existing statement + final iconPathRegex = RegExp(r'"([^"]+)"'); + final iconPathMatch = iconPathRegex.firstMatch(existingIconStatement); + + if (iconPathMatch != null) { + final currentIconPath = iconPathMatch.group(1)!; + + if (currentIconPath == iconPath) { + context.logger.verbose( + 'Icon configuration already exists with correct path: $iconPath', + ); + return; + } else { + context.logger.verbose( + 'Updating icon configuration from "$currentIconPath" to "$iconPath"', + ); + + // Replace the existing icon statement with the new one + final updatedContent = + content.replaceFirst(existingIconRegex, iconLine); + await myAppFile.writeAsString(updatedContent); + + context.logger.verbose( + 'Updated my_application.cc with new icon configuration: $iconPath', + ); + return; + } + } + } + + // No existing icon configuration found - need to add it + context.logger.verbose('Adding new icon configuration: $iconPath'); + + final lines = content.split('\n'); + bool modified = false; + + // Strategy 1: Find gtk_window_set_default_size and insert before it + for (int i = 0; i < lines.length; i++) { + if (lines[i].contains('gtk_window_set_default_size')) { + // Handle multi-line gtk_window_set_default_size calls + final int insertIndex = i; + + // Find the proper indentation by looking at the current line + final currentLine = lines[i]; + final leadingWhitespace = + RegExp(r'^(\s*)').firstMatch(currentLine)?.group(1) ?? ' '; + + lines.insert(insertIndex, '$leadingWhitespace$iconLine'); + modified = true; + break; + } + } + + // Strategy 2: Find window variable declaration and insert after it + if (!modified) { + for (int i = 0; i < lines.length; i++) { + if (lines[i].contains('GtkWindow* window =') || + lines[i].contains('GtkWindow *window =')) { + // Find the end of the window declaration (look for semicolon) + int declarationEndIndex = i; + while (declarationEndIndex < lines.length && + !lines[declarationEndIndex].contains(';')) { + declarationEndIndex++; + } + + // Insert after the window declaration + final int insertIndex = declarationEndIndex + 1; + + // Find proper indentation + final currentLine = lines[i]; + final leadingWhitespace = + RegExp(r'^(\s*)').firstMatch(currentLine)?.group(1) ?? ' '; + + lines.insert(insertIndex, '$leadingWhitespace$iconLine'); + modified = true; + break; + } + } + } + + // Strategy 3: Find gtk_window_show and insert before it + if (!modified) { + for (int i = 0; i < lines.length; i++) { + if (lines[i].contains('gtk_window_show') || + lines[i].contains('gtk_widget_show')) { + // Find proper indentation + final currentLine = lines[i]; + final leadingWhitespace = + RegExp(r'^(\s*)').firstMatch(currentLine)?.group(1) ?? ' '; + + lines.insert(i, '$leadingWhitespace$iconLine'); + modified = true; + break; + } + } + } + + // Strategy 4: Find any gtk_window function call and insert nearby + if (!modified) { + for (int i = 0; i < lines.length; i++) { + if (lines[i].contains('gtk_window_') && + !lines[i].contains('gtk_window_set_icon_from_file')) { + // Find proper indentation + final currentLine = lines[i]; + final leadingWhitespace = + RegExp(r'^(\s*)').firstMatch(currentLine)?.group(1) ?? ' '; + + lines.insert(i + 1, '$leadingWhitespace$iconLine'); + modified = true; + break; + } + } + } + + if (modified) { + await myAppFile.writeAsString(lines.join('\n')); + context.logger.verbose( + 'Updated my_application.cc with icon configuration: $iconPath', + ); + } else { + final errorMessage = + 'Could not find appropriate location to add icon configuration in my_application.cc. ' + 'Please manually add the following line after the window creation:\n' + ' gtk_window_set_icon_from_file(window, "$iconPath", NULL);'; + context.logger.error(errorMessage); + throw Exception('Failed to update my_application.cc. $errorMessage'); + } + } + + Future _validatePubspecAssets(String iconPath) async { + final pubspecFile = File(path.join(context.prefixPath, 'pubspec.yaml')); + + if (!pubspecFile.existsSync()) { + context.logger.error('pubspec.yaml not found'); + return; + } + + final content = await pubspecFile.readAsString(); + final yamlDoc = loadYaml(content) as Map?; + + if (yamlDoc == null) { + context.logger.error('Could not parse pubspec.yaml'); + return; + } + + final flutter = yamlDoc['flutter'] as Map?; + if (flutter == null) { + context.logger.error( + 'No flutter section found in pubspec.yaml. Please add $iconPath to your flutter.assets section.', + ); + return; + } + + final assets = flutter['assets'] as List?; + if (assets == null) { + context.logger.error( + 'No assets section found in pubspec.yaml. Please add "$iconPath" to your flutter.assets section.', + ); + return; + } + + // Check if the icon path or its directory is included in assets + final iconDir = path.dirname(iconPath) + '/'; + bool hasIconPath = false; + + for (final asset in assets) { + final assetStr = asset.toString(); + if (assetStr == iconPath || assetStr == iconDir) { + hasIconPath = true; + break; + } + } + + if (!hasIconPath) { + context.logger.error( + 'Icon path $iconPath not found in pubspec.yaml assets. Please add "$iconPath" or "$iconDir" to your flutter.assets section.', + ); + } else { + context.logger.verbose( + 'Icon path $iconPath is properly configured in pubspec.yaml', + ); + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 0bc7805e3e..27a0d92fe2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:flutter_launcher_icons/constants.dart' as constants; import 'package:flutter_launcher_icons/constants.dart'; import 'package:flutter_launcher_icons/custom_exceptions.dart'; import 'package:flutter_launcher_icons/ios.dart' as ios_launcher_icons; +import 'package:flutter_launcher_icons/linux/linux_icon_generator.dart'; import 'package:flutter_launcher_icons/logger.dart'; import 'package:flutter_launcher_icons/macos/macos_icon_generator.dart'; import 'package:flutter_launcher_icons/web/web_icon_generator.dart'; @@ -178,6 +179,9 @@ Future createIconsFromConfig( if (flutterConfigs.hasMacOSConfig) { platforms.add(MacOSIconGenerator(context)); } + if (flutterConfigs.hasLinuxConfig) { + platforms.add(LinuxIconGenerator(context)); + } return platforms; }, ); diff --git a/test/abs/icon_generator_test.mocks.dart b/test/abs/icon_generator_test.mocks.dart index e5abdf1ed8..580b899408 100644 --- a/test/abs/icon_generator_test.mocks.dart +++ b/test/abs/icon_generator_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.6 from annotations // in flutter_launcher_icons/test/abs/icon_generator_test.dart. // Do not manually edit this file. @@ -18,6 +18,7 @@ import 'package:mockito/src/dummies.dart' as _i4; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types @@ -111,6 +112,12 @@ class MockConfig extends _i1.Mock implements _i3.Config { returnValue: false, ) as bool); + @override + bool get hasLinuxConfig => (super.noSuchMethod( + Invocation.getter(#hasLinuxConfig), + returnValue: false, + ) as bool); + @override bool get isCustomAndroidFile => (super.noSuchMethod( Invocation.getter(#isCustomAndroidFile), diff --git a/test/linux/linux_icon_generator_test.dart b/test/linux/linux_icon_generator_test.dart new file mode 100644 index 0000000000..4f8853a9d2 --- /dev/null +++ b/test/linux/linux_icon_generator_test.dart @@ -0,0 +1,709 @@ +import 'dart:io'; + +import 'package:flutter_launcher_icons/abs/icon_generator.dart'; +import 'package:flutter_launcher_icons/config/config.dart'; +import 'package:flutter_launcher_icons/config/linux_config.dart'; +import 'package:flutter_launcher_icons/linux/linux_icon_generator.dart'; +import 'package:flutter_launcher_icons/logger.dart'; +import 'package:test/test.dart'; + +void main() { + group('LinuxIconGenerator', () { + late IconGeneratorContext context; + late LinuxIconGenerator generator; + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('linux_icon_test'); + + const config = Config( + imagePath: 'assets/images/icon.png', + linuxConfig: LinuxConfig(generate: true), + ); + + context = IconGeneratorContext( + config: config, + logger: FLILogger(false), + prefixPath: tempDir.path, + ); + + generator = LinuxIconGenerator(context); + }); + + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + test('validateRequirements returns false when generate is false', () { + const config = Config( + imagePath: 'assets/images/icon.png', + linuxConfig: LinuxConfig(generate: false), + ); + + final testContext = IconGeneratorContext( + config: config, + logger: FLILogger(false), + prefixPath: tempDir.path, + ); + + final testGenerator = LinuxIconGenerator(testContext); + + expect(testGenerator.validateRequirements(), isFalse); + }); + + test('validateRequirements returns false when no image path provided', () { + const config = Config( + linuxConfig: LinuxConfig(generate: true), + ); + + final testContext = IconGeneratorContext( + config: config, + logger: FLILogger(false), + prefixPath: tempDir.path, + ); + + final testGenerator = LinuxIconGenerator(testContext); + + expect(testGenerator.validateRequirements(), isFalse); + }); + + test('validateRequirements returns false when image path is not in assets', + () { + const config = Config( + imagePath: 'images/icon.png', // Not in assets/ + linuxConfig: LinuxConfig(generate: true), + ); + + final testContext = IconGeneratorContext( + config: config, + logger: FLILogger(false), + prefixPath: tempDir.path, + ); + + final testGenerator = LinuxIconGenerator(testContext); + + expect(testGenerator.validateRequirements(), isFalse); + }); + + test( + 'validateRequirements returns false when linux directory does not exist', + () { + expect(generator.validateRequirements(), isFalse); + }); + + test('validateRequirements returns true when all requirements are met', + () async { + // Create linux directory structure + final linuxDir = Directory('${tempDir.path}/linux'); + await linuxDir.create(); + final runnerDir = Directory('${tempDir.path}/linux/runner'); + await runnerDir.create(); + + // Create my_application.cc file + final myAppFile = File('${tempDir.path}/linux/runner/my_application.cc'); + await myAppFile.create(); + + // Create test icon file in assets + final assetsDir = Directory('${tempDir.path}/assets/images'); + await assetsDir.create(recursive: true); + final iconFile = File('${tempDir.path}/assets/images/icon.png'); + await iconFile.writeAsBytes([0]); // dummy data + + expect(generator.validateRequirements(), isTrue); + }); + + test('platform name is Linux', () { + expect(generator.platformName, equals('Linux')); + }); + + test('createIcons throws exception when icon path is not in assets', + () async { + const config = Config( + imagePath: 'images/icon.png', // Not in assets/ + linuxConfig: LinuxConfig(generate: true), + ); + + final testContext = IconGeneratorContext( + config: config, + logger: FLILogger(false), + prefixPath: tempDir.path, + ); + + final testGenerator = LinuxIconGenerator(testContext); + + expect( + () => testGenerator.createIcons(), + throwsA(isA()), + ); + }); + + group('my_application.cc modifications', () { + late File myAppFile; + late Directory assetsDir; + late File iconFile; + + setUp(() async { + // Create linux directory structure + final linuxDir = Directory('${tempDir.path}/linux'); + await linuxDir.create(); + final runnerDir = Directory('${tempDir.path}/linux/runner'); + await runnerDir.create(); + + // Create my_application.cc file + myAppFile = File('${tempDir.path}/linux/runner/my_application.cc'); + + // Create test icon file in assets + assetsDir = Directory('${tempDir.path}/assets/images'); + await assetsDir.create(recursive: true); + iconFile = File('${tempDir.path}/assets/images/icon.png'); + await iconFile.writeAsBytes([0]); // dummy data + }); + + test('adds icon line before gtk_window_set_default_size', () async { + const originalContent = ''' +#include "my_application.h" + +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); +} +'''; + + await myAppFile.writeAsString(originalContent); + await generator.createIcons(); + + final modifiedContent = await myAppFile.readAsString(); + expect( + modifiedContent, + contains( + 'gtk_window_set_icon_from_file(window, "assets/images/icon.png", NULL);', + ), + ); + expect( + modifiedContent, + contains( + 'gtk_window_set_icon_from_file(window, "assets/images/icon.png", NULL);\n gtk_window_set_default_size(window, 1280, 720);', + ), + ); + }); + + test('uses fallback location after window declaration for single line', + () async { + const originalContent = ''' +#include "my_application.h" + +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + gtk_widget_show(GTK_WIDGET(window)); +} +'''; + + await myAppFile.writeAsString(originalContent); + await generator.createIcons(); + + final modifiedContent = await myAppFile.readAsString(); + expect( + modifiedContent, + contains( + 'gtk_window_set_icon_from_file(window, "assets/images/icon.png", NULL);', + ), + ); + // Should be inserted after the window declaration line + final lines = modifiedContent.split('\n'); + final windowLineIndex = + lines.indexWhere((line) => line.contains('GtkWindow* window =')); + final iconLineIndex = lines.indexWhere( + (line) => line.contains('gtk_window_set_icon_from_file'), + ); + expect(iconLineIndex, greaterThan(windowLineIndex)); + }); + + test('handles multiline window declaration', () async { + const originalContent = ''' +#include "my_application.h" + +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new( + GTK_APPLICATION(application))); + gtk_widget_show(GTK_WIDGET(window)); +} +'''; + + await myAppFile.writeAsString(originalContent); + await generator.createIcons(); + + final modifiedContent = await myAppFile.readAsString(); + expect( + modifiedContent, + contains( + 'gtk_window_set_icon_from_file(window, "assets/images/icon.png", NULL);', + ), + ); + // Should be inserted after the multiline window declaration + final lines = modifiedContent.split('\n'); + final windowLineIndex = + lines.indexWhere((line) => line.contains('GtkWindow* window =')); + final iconLineIndex = lines.indexWhere( + (line) => line.contains('gtk_window_set_icon_from_file'), + ); + expect(iconLineIndex, greaterThan(windowLineIndex)); + }); + + test('uses custom icon path from linux config', () async { + const config = Config( + imagePath: 'assets/images/default.png', + linuxConfig: LinuxConfig( + generate: true, + imagePath: 'assets/icons/custom.png', + ), + ); + + final testContext = IconGeneratorContext( + config: config, + logger: FLILogger(false), + prefixPath: tempDir.path, + ); + + final testGenerator = LinuxIconGenerator(testContext); + + // Create custom icon file + final iconsDir = Directory('${tempDir.path}/assets/icons'); + await iconsDir.create(recursive: true); + final customIconFile = File('${tempDir.path}/assets/icons/custom.png'); + await customIconFile.writeAsBytes([0]); + + const originalContent = ''' +#include "my_application.h" + +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); +} +'''; + + await myAppFile.writeAsString(originalContent); + await testGenerator.createIcons(); + + final modifiedContent = await myAppFile.readAsString(); + expect( + modifiedContent, + contains( + 'gtk_window_set_icon_from_file(window, "assets/icons/custom.png", NULL);', + ), + ); + }); + + test( + 'does not add duplicate icon line when already present with same path', + () async { + const originalContent = ''' +#include "my_application.h" + +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + gtk_window_set_icon_from_file(window, "assets/images/icon.png", NULL); + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); +} +'''; + + await myAppFile.writeAsString(originalContent); + await generator.createIcons(); + + final modifiedContent = await myAppFile.readAsString(); + // Should not add a new icon line + final iconLines = modifiedContent + .split('\n') + .where((line) => line.contains('gtk_window_set_icon_from_file')) + .toList(); + expect(iconLines.length, equals(1)); + // Should keep the existing icon line unchanged + expect( + modifiedContent, + contains( + 'gtk_window_set_icon_from_file(window, "assets/images/icon.png", NULL);', + ), + ); + }); + + test('updates existing icon line when path is different', () async { + const originalContent = ''' +#include "my_application.h" + +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + gtk_window_set_icon_from_file(window, "assets/images/old_icon.png", NULL); + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); +} +'''; + + await myAppFile.writeAsString(originalContent); + await generator.createIcons(); + + final modifiedContent = await myAppFile.readAsString(); + // Should update the existing icon line + final iconLines = modifiedContent + .split('\n') + .where((line) => line.contains('gtk_window_set_icon_from_file')) + .toList(); + expect(iconLines.length, equals(1)); + // Should have the new icon path + expect( + modifiedContent, + contains( + 'gtk_window_set_icon_from_file(window, "assets/images/icon.png", NULL);', + ), + ); + // Should not contain the old icon path + expect(modifiedContent, isNot(contains('old_icon.png'))); + }); + + test('handles multi-line icon configuration with weird formatting', + () async { + const originalContent = ''' +#include "my_application.h" + +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + gtk_window_set_icon_from_file(window, + "assets/images/old_icon.png", + NULL); + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); +} +'''; + + await myAppFile.writeAsString(originalContent); + await generator.createIcons(); + + final modifiedContent = await myAppFile.readAsString(); + // Should update the multi-line icon configuration + final iconLines = modifiedContent + .split('\n') + .where((line) => line.contains('gtk_window_set_icon_from_file')) + .toList(); + expect(iconLines.length, equals(1)); + // Should have the new icon path in a clean single line + expect( + modifiedContent, + contains( + 'gtk_window_set_icon_from_file(window, "assets/images/icon.png", NULL);', + ), + ); + // Should not contain the old icon path + expect(modifiedContent, isNot(contains('old_icon.png'))); + }); + + test('handles icon configuration with extra whitespace and formatting', + () async { + const originalContent = ''' +#include "my_application.h" + +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + gtk_window_set_icon_from_file( window , "assets/images/old_icon.png" , NULL ); + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); +} +'''; + + await myAppFile.writeAsString(originalContent); + await generator.createIcons(); + + final modifiedContent = await myAppFile.readAsString(); + // Should update the icon configuration regardless of whitespace + final iconLines = modifiedContent + .split('\n') + .where((line) => line.contains('gtk_window_set_icon_from_file')) + .toList(); + expect(iconLines.length, equals(1)); + // Should have the new icon path + expect( + modifiedContent, + contains( + 'gtk_window_set_icon_from_file(window, "assets/images/icon.png", NULL);', + ), + ); + // Should not contain the old icon path + expect(modifiedContent, isNot(contains('old_icon.png'))); + }); + + test('uses fallback strategy 3: inserts before gtk_window_show', + () async { + const originalContent = ''' +#include "my_application.h" + +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + gtk_window_show(window); +} +'''; + + await myAppFile.writeAsString(originalContent); + await generator.createIcons(); + + final modifiedContent = await myAppFile.readAsString(); + expect( + modifiedContent, + contains( + 'gtk_window_set_icon_from_file(window, "assets/images/icon.png", NULL);', + ), + ); + // Should be inserted before gtk_window_show + final lines = modifiedContent.split('\n'); + final iconLineIndex = lines.indexWhere( + (line) => line.contains('gtk_window_set_icon_from_file'), + ); + final showLineIndex = + lines.indexWhere((line) => line.contains('gtk_window_show')); + expect(iconLineIndex, lessThan(showLineIndex)); + }); + + test('uses fallback strategy 3: inserts before gtk_widget_show', + () async { + const originalContent = ''' +#include "my_application.h" + +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + gtk_widget_show(GTK_WIDGET(window)); +} +'''; + + await myAppFile.writeAsString(originalContent); + await generator.createIcons(); + + final modifiedContent = await myAppFile.readAsString(); + expect( + modifiedContent, + contains( + 'gtk_window_set_icon_from_file(window, "assets/images/icon.png", NULL);', + ), + ); + // Should be inserted before gtk_widget_show + final lines = modifiedContent.split('\n'); + final iconLineIndex = lines.indexWhere( + (line) => line.contains('gtk_window_set_icon_from_file'), + ); + final showLineIndex = + lines.indexWhere((line) => line.contains('gtk_widget_show')); + expect(iconLineIndex, lessThan(showLineIndex)); + }); + + test('uses fallback strategy 4: inserts after any gtk_window function', + () async { + const originalContent = ''' +#include "my_application.h" + +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + // window is passed as parameter, so no window declaration to trigger strategy 2 + gtk_window_set_title(window, "My App"); + // No other gtk_window functions that would trigger earlier strategies +} +'''; + + await myAppFile.writeAsString(originalContent); + await generator.createIcons(); + + final modifiedContent = await myAppFile.readAsString(); + expect( + modifiedContent, + contains( + 'gtk_window_set_icon_from_file(window, "assets/images/icon.png", NULL);', + ), + ); + // Should be inserted after gtk_window_set_title + final lines = modifiedContent.split('\n'); + final titleLineIndex = + lines.indexWhere((line) => line.contains('gtk_window_set_title')); + final iconLineIndex = lines.indexWhere( + (line) => line.contains('gtk_window_set_icon_from_file'), + ); + expect(iconLineIndex, greaterThan(titleLineIndex)); + }); + + test('preserves indentation from surrounding code', () async { + const originalContent = ''' +#include "my_application.h" + +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); +} +'''; + + await myAppFile.writeAsString(originalContent); + await generator.createIcons(); + + final modifiedContent = await myAppFile.readAsString(); + // Should preserve the 4-space indentation from the surrounding code + expect( + modifiedContent, + contains( + ' gtk_window_set_icon_from_file(window, "assets/images/icon.png", NULL);', + ), + ); + }); + + test('handles window declaration with different pointer syntax', + () async { + const originalContent = ''' +#include "my_application.h" + +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow *window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + gtk_widget_show(GTK_WIDGET(window)); +} +'''; + + await myAppFile.writeAsString(originalContent); + await generator.createIcons(); + + final modifiedContent = await myAppFile.readAsString(); + expect( + modifiedContent, + contains( + 'gtk_window_set_icon_from_file(window, "assets/images/icon.png", NULL);', + ), + ); + // Should be inserted after the window declaration + final lines = modifiedContent.split('\n'); + final windowLineIndex = + lines.indexWhere((line) => line.contains('GtkWindow *window =')); + final iconLineIndex = lines.indexWhere( + (line) => line.contains('gtk_window_set_icon_from_file'), + ); + expect(iconLineIndex, greaterThan(windowLineIndex)); + }); + + test( + 'handles complex multi-line window declaration with multiple GTK calls', + () async { + const originalContent = ''' +#include "my_application.h" + +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW( + gtk_application_window_new( + GTK_APPLICATION(application))); + gtk_widget_show(GTK_WIDGET(window)); +} +'''; + + await myAppFile.writeAsString(originalContent); + await generator.createIcons(); + + final modifiedContent = await myAppFile.readAsString(); + expect( + modifiedContent, + contains( + 'gtk_window_set_icon_from_file(window, "assets/images/icon.png", NULL);', + ), + ); + // Should be inserted after the complete window declaration + final lines = modifiedContent.split('\n'); + final windowLineIndex = + lines.indexWhere((line) => line.contains('GtkWindow* window =')); + final iconLineIndex = lines.indexWhere( + (line) => line.contains('gtk_window_set_icon_from_file'), + ); + expect(iconLineIndex, greaterThan(windowLineIndex)); + // Should be before the show call + final showLineIndex = + lines.indexWhere((line) => line.contains('gtk_widget_show')); + expect(iconLineIndex, lessThan(showLineIndex)); + }); + + test('provides helpful error message when no insertion point found', + () async { + const originalContent = ''' +#include "my_application.h" + +static void my_application_activate(GApplication* application) { + // Some other content without the expected patterns + g_print("Hello World\\n"); +} +'''; + + await myAppFile.writeAsString(originalContent); + + try { + await generator.createIcons(); + fail('Expected an exception to be thrown'); + } catch (e) { + expect(e.toString(), contains('Failed to update my_application.cc')); + // The error message should contain the manual instruction + expect( + e.toString(), + contains( + 'gtk_window_set_icon_from_file(window, "assets/images/icon.png", NULL);', + ), + ); + } + }); + + test('handles deeply nested statements with proper detection', () async { + const originalContent = ''' +#include "my_application.h" + +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + if (some_condition) { + gtk_window_set_icon_from_file( + window, + "assets/images/nested_icon.png", + NULL + ); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); +} +'''; + + await myAppFile.writeAsString(originalContent); + await generator.createIcons(); + + final modifiedContent = await myAppFile.readAsString(); + // Should update the nested icon configuration + final iconLines = modifiedContent + .split('\n') + .where((line) => line.contains('gtk_window_set_icon_from_file')) + .toList(); + expect(iconLines.length, equals(1)); + // Should have the new icon path + expect( + modifiedContent, + contains( + 'gtk_window_set_icon_from_file(window, "assets/images/icon.png", NULL);', + ), + ); + // Should not contain the old icon path + expect(modifiedContent, isNot(contains('nested_icon.png'))); + }); + }); + }); +} diff --git a/test/macos/macos_icon_generator_test.mocks.dart b/test/macos/macos_icon_generator_test.mocks.dart index be401029fa..57cff80fe4 100644 --- a/test/macos/macos_icon_generator_test.mocks.dart +++ b/test/macos/macos_icon_generator_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.6 from annotations // in flutter_launcher_icons/test/macos/macos_icon_generator_test.dart. // Do not manually edit this file. @@ -18,6 +18,7 @@ import 'package:mockito/src/dummies.dart' as _i4; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types @@ -130,6 +131,13 @@ class MockConfig extends _i1.Mock implements _i3.Config { returnValueForMissingStub: false, ) as bool); + @override + bool get hasLinuxConfig => (super.noSuchMethod( + Invocation.getter(#hasLinuxConfig), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + @override bool get isCustomAndroidFile => (super.noSuchMethod( Invocation.getter(#isCustomAndroidFile), diff --git a/test/windows/windows_icon_generator_test.mocks.dart b/test/windows/windows_icon_generator_test.mocks.dart index eb40839341..a14d7c34a7 100644 --- a/test/windows/windows_icon_generator_test.mocks.dart +++ b/test/windows/windows_icon_generator_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.6 from annotations // in flutter_launcher_icons/test/windows/windows_icon_generator_test.dart. // Do not manually edit this file. @@ -18,6 +18,7 @@ import 'package:mockito/src/dummies.dart' as _i4; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types @@ -120,6 +121,12 @@ class MockConfig extends _i1.Mock implements _i3.Config { returnValue: false, ) as bool); + @override + bool get hasLinuxConfig => (super.noSuchMethod( + Invocation.getter(#hasLinuxConfig), + returnValue: false, + ) as bool); + @override bool get isCustomAndroidFile => (super.noSuchMethod( Invocation.getter(#isCustomAndroidFile),