diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index a33ac97d..5c376b78 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -20,7 +20,7 @@ env: jobs: build-windows: name: Build Windows - runs-on: windows-2022 + runs-on: windows-latest # ----------------------------------- Setup ------------------------------ # diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 65f8f6cc..4c4c957d 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -23,17 +23,16 @@ jobs: with: pre-release: true secrets: inherit - # call-build-windows: - # needs: call-tests - # uses: ./.github/workflows/build-windows.yml - # with: - # pre-release: true - # secrets: inherit + call-build-windows: + needs: call-tests + uses: ./.github/workflows/build-windows.yml + with: + pre-release: true + secrets: inherit pre-release: name: "Pre Release" - # needs: [call-build-linux, call-build-windows] - needs: [call-build-linux] + needs: [call-build-linux, call-build-windows] runs-on: "ubuntu-latest" steps: @@ -53,4 +52,4 @@ jobs: title: "Development Build" files: | ${{ github.workspace }}/artifacts/linux-artifacts/* - # ${{ github.workspace }}/artifacts/windows-artifacts/* + ${{ github.workspace }}/artifacts/windows-artifacts/* diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 8d7a872a..429bb354 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -21,17 +21,16 @@ jobs: with: pre-release: true secrets: inherit - # call-build-windows: - # needs: call-tests - # uses: ./.github/workflows/build-windows.yml - # with: - # pre-release: true - # secrets: inherit + call-build-windows: + needs: call-tests + uses: ./.github/workflows/build-windows.yml + with: + pre-release: true + secrets: inherit verify-pull-request: name: Verify Pull Request - # needs: [call-tests, call-build-linux, call-build-windows] - needs: [call-tests, call-build-linux] + needs: [call-tests, call-build-linux, call-build-windows] runs-on: ubuntu-latest steps: - run: echo "Requirements passed, PR looks good!" diff --git a/.github/workflows/release-windows-store.yml b/.github/workflows/release-windows-store.yml index 6b8dca11..2e78a377 100644 --- a/.github/workflows/release-windows-store.yml +++ b/.github/workflows/release-windows-store.yml @@ -4,9 +4,9 @@ on: # Enable manual run workflow_dispatch: # Build & deploy for published releases - # release: - # types: - # - published + release: + types: + - published concurrency: group: ci-release-${{ github.ref }}-1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7a723142..0c1c9047 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,17 +22,16 @@ jobs: with: pre-release: false secrets: inherit - # call-build-windows: - # needs: call-tests - # uses: ./.github/workflows/build-windows.yml - # with: - # pre-release: false - # secrets: inherit + call-build-windows: + needs: call-tests + uses: ./.github/workflows/build-windows.yml + with: + pre-release: false + secrets: inherit release: name: "Release" - # needs: [call-build-linux, call-build-windows] - needs: [call-build-linux] + needs: [call-build-linux, call-build-windows] runs-on: "ubuntu-latest" steps: @@ -51,4 +50,4 @@ jobs: prerelease: false files: | ${{ github.workspace }}/artifacts/linux-artifacts/* -# ${{ github.workspace }}/artifacts/windows-artifacts/* + ${{ github.workspace }}/artifacts/windows-artifacts/* diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3826f9e3..9c2d7297 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,9 +12,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - # os: [ubuntu-latest, windows-latest] - os: [ubuntu-latest] - + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v3 - uses: subosito/flutter-action@v2 diff --git a/README.md b/README.md index e104933f..37e9cf31 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,7 @@ Unfortunately no. [CRIU](https://criu.org/) looks very promising to allow us to ## Troubleshooting -The Windows version is unlikely to receive further updates, and is not as stable -as the Linux version. - -If you use Microsoft Windows, issues are often resolved by running Nyrna as an -administrator. +If you use Microsoft Windows, issues are often resolved by running Nyrna as an administrator. ## Building diff --git a/lib/app/cubit/app_cubit.dart b/lib/app/cubit/app_cubit.dart index 3d3a2584..f5796483 100644 --- a/lib/app/cubit/app_cubit.dart +++ b/lib/app/cubit/app_cubit.dart @@ -56,11 +56,13 @@ class AppCubit extends Cubit { /// Lazy loading is used instead of awaiting on a constructor to avoid /// blocking the UI, since none of the data fetched here is critical. Future _init() async { + // Set up tray listener immediately so no events are missed while the + // async initialization below is in progress. + _listenToSystemTrayEvents(); await _checkForFirstRun(); _checkLinuxSessionType(); await _fetchVersionData(); await _fetchReleaseNotes(); - _listenToSystemTrayEvents(); } /// Checks if this is the first run of the app. @@ -129,10 +131,6 @@ Otherwise, [consider signing in using X11 instead](https://docs.fedoraproject.or /// Fetches version data from the update service. Future _fetchVersionData() async { - // Windows version is not being supported for future updates, so we skip fetching - // version data for Windows so they don't get an "update available" message. - if (Platform.isWindows) return; - final VersionInfo versionInfo; try { versionInfo = await _updateService.getVersionInfo(); @@ -155,6 +153,7 @@ Otherwise, [consider signing in using X11 instead](https://docs.fedoraproject.or /// Fetches release notes from the release notes service. Future _fetchReleaseNotes() async { if (state.firstRun) return; + if (state.runningVersion.isEmpty) return; final String? lastReleaseNotesVersionShown = await _storageRepository.getValue( 'lastReleaseNotesVersionShown', diff --git a/lib/hotkey/hotkey.dart b/lib/hotkey/hotkey.dart index d15a14ba..de22aa01 100644 --- a/lib/hotkey/hotkey.dart +++ b/lib/hotkey/hotkey.dart @@ -1,2 +1,3 @@ export 'global/global.dart'; +export 'hotkey_label.dart'; export 'in_app/in_app.dart'; diff --git a/lib/hotkey/hotkey_label.dart b/lib/hotkey/hotkey_label.dart new file mode 100644 index 00000000..e3786983 --- /dev/null +++ b/lib/hotkey/hotkey_label.dart @@ -0,0 +1,76 @@ +import 'package:flutter/services.dart'; +import 'package:hotkey_manager/hotkey_manager.dart'; + +/// Physical keys whose names are not available in release builds via +/// [PhysicalKeyboardKey.debugName] (which is assert-only in Flutter) and are +/// also absent from hotkey_manager's built-in label map. +final _extraPhysicalKeyLabels = { + PhysicalKeyboardKey.pause: 'Pause', + PhysicalKeyboardKey.insert: 'Insert', + PhysicalKeyboardKey.printScreen: 'Print Screen', + PhysicalKeyboardKey.scrollLock: 'Scroll Lock', + PhysicalKeyboardKey.numLock: 'Num Lock', + PhysicalKeyboardKey.numpadAdd: 'Num +', + PhysicalKeyboardKey.numpadSubtract: 'Num -', + PhysicalKeyboardKey.numpadMultiply: 'Num *', + PhysicalKeyboardKey.numpadDivide: 'Num /', + PhysicalKeyboardKey.numpadEnter: 'Num Enter', + PhysicalKeyboardKey.numpadDecimal: 'Num .', + PhysicalKeyboardKey.numpad0: 'Num 0', + PhysicalKeyboardKey.numpad1: 'Num 1', + PhysicalKeyboardKey.numpad2: 'Num 2', + PhysicalKeyboardKey.numpad3: 'Num 3', + PhysicalKeyboardKey.numpad4: 'Num 4', + PhysicalKeyboardKey.numpad5: 'Num 5', + PhysicalKeyboardKey.numpad6: 'Num 6', + PhysicalKeyboardKey.numpad7: 'Num 7', + PhysicalKeyboardKey.numpad8: 'Num 8', + PhysicalKeyboardKey.numpad9: 'Num 9', + PhysicalKeyboardKey.f13: 'F13', + PhysicalKeyboardKey.f14: 'F14', + PhysicalKeyboardKey.f15: 'F15', + PhysicalKeyboardKey.f16: 'F16', + PhysicalKeyboardKey.f17: 'F17', + PhysicalKeyboardKey.f18: 'F18', + PhysicalKeyboardKey.f19: 'F19', + PhysicalKeyboardKey.f20: 'F20', + PhysicalKeyboardKey.f21: 'F21', + PhysicalKeyboardKey.f22: 'F22', + PhysicalKeyboardKey.f23: 'F23', + PhysicalKeyboardKey.f24: 'F24', +}; + +/// Human-readable labels for [HotKeyModifier] values. +/// +/// [HotKeyModifier.physicalKeys] also relies on [PhysicalKeyboardKey.debugName] +/// indirectly, so we use a static map here instead. +const _modifierLabels = { + HotKeyModifier.alt: 'Alt', + HotKeyModifier.capsLock: '⇪', + HotKeyModifier.control: 'Ctrl', + HotKeyModifier.fn: 'fn', + HotKeyModifier.meta: '⊞', + HotKeyModifier.shift: 'Shift', +}; + +/// Returns a human-readable label for [hotKey] that is safe in release builds. +/// +/// [HotKey.debugName] uses [PhysicalKeyboardKey.debugName] which is +/// assert-only and returns `null` outside of debug mode. +String hotkeyLabel(HotKey hotKey) { + PhysicalKeyboardKey? physicalKey; + if (hotKey.key is PhysicalKeyboardKey) { + physicalKey = hotKey.key as PhysicalKeyboardKey; + } + + // hotkey_manager's KeyboardKeyExt.keyLabel covers most standard keys but + // also falls back to debugName for unknown ones, so we check our extended + // map first. + final keyName = + (physicalKey != null ? _extraPhysicalKeyLabels[physicalKey] : null) ?? + hotKey.key.keyLabel; + + final modifierNames = (hotKey.modifiers ?? []).map((m) => _modifierLabels[m] ?? m.name); + + return [...modifierNames, keyName].join(' + '); +} diff --git a/lib/settings/widgets/integration_section.dart b/lib/settings/widgets/integration_section.dart index ee36dac0..44b0ec0b 100644 --- a/lib/settings/widgets/integration_section.dart +++ b/lib/settings/widgets/integration_section.dart @@ -6,6 +6,7 @@ import 'package:hotkey_manager/hotkey_manager.dart'; import '../../app/app.dart'; import '../../apps_list/apps_list.dart'; import '../../core/core.dart'; +import '../../hotkey/hotkey.dart'; import '../../localization/app_localizations.dart'; import '../../native_platform/native_platform.dart'; import '../../theme/styles.dart'; @@ -169,7 +170,7 @@ class _HotkeyConfigWidget extends StatelessWidget { ), child: BlocBuilder( builder: (context, state) { - return Text(state.hotKey.debugName); + return Text(hotkeyLabel(state.hotKey)); }, ), ), @@ -302,7 +303,7 @@ class _AppSpecificHotkeys extends StatelessWidget { child: Card( elevation: 2, child: ListTile( - leading: Text(hotkey.hotkey.debugName), + leading: Text(hotkeyLabel(hotkey.hotkey)), title: Text(hotkey.executable), trailing: ElevatedButton( onPressed: () => settingsCubit.removeAppSpecificHotkey( diff --git a/lib/system_tray/system_tray_manager.dart b/lib/system_tray/system_tray_manager.dart index 00129033..75657579 100644 --- a/lib/system_tray/system_tray_manager.dart +++ b/lib/system_tray/system_tray_manager.dart @@ -19,6 +19,10 @@ class SystemTrayManager { final _eventStreamController = StreamController.broadcast(); Future initialize() async { + if (defaultTargetPlatform.isWindows) { + await trayManager.setToolTip('Nyrna'); + } + final String iconPath; if (runningInFlatpak() || runningInSnap()) { diff --git a/pubspec.lock b/pubspec.lock index 9b5db381..105305d4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -491,12 +491,13 @@ packages: source: hosted version: "0.2.0" hotkey_manager_windows: - dependency: transitive + dependency: "direct overridden" description: - name: hotkey_manager_windows - sha256: "0d03ced9fe563ed0b68f0a0e1b22c9ffe26eb8053cb960e401f68a4f070e0117" - url: "https://pub.dev" - source: hosted + path: "packages/hotkey_manager_windows" + ref: a031e87df1ae03a2dd13bb03ea2cc2e5b484a0a7 + resolved-ref: a031e87df1ae03a2dd13bb03ea2cc2e5b484a0a7 + url: "https://github.com/Merrit/hotkey_manager.git" + source: git version: "0.2.0" http: dependency: "direct main" @@ -644,10 +645,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -668,10 +669,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -1089,26 +1090,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a" + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" url: "https://pub.dev" source: hosted - version: "1.29.0" + version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.11" test_core: dependency: transitive description: name: test_core - sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943" + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" url: "https://pub.dev" source: hosted - version: "0.6.15" + version: "0.6.17" tray_manager: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 19e820ae..e7476973 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,6 +61,11 @@ dependency_overrides: url: https://github.com/gepbird/hotkey_manager.git path: packages/hotkey_manager_linux ref: 1b31e3ca4ab017ee72f54a7065b2073a88403d61 + hotkey_manager_windows: + git: + url: https://github.com/Merrit/hotkey_manager.git + path: packages/hotkey_manager_windows + ref: a031e87df1ae03a2dd13bb03ea2cc2e5b484a0a7 dev_dependencies: build_runner: ^2.4.6 diff --git a/test/window/app_window_test.dart b/test/window/app_window_test.dart index cc945751..196986ce 100644 --- a/test/window/app_window_test.dart +++ b/test/window/app_window_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -178,52 +179,60 @@ void main() { }); group('setWindowSizeAndPosition:', () { - test('reads saved rect from storage and does not call saveValue', () async { - const savedJson = '{"left":100.0,"top":200.0,"width":530.0,"height":600.0}'; - when( - mockStorageRepo.getValue( - _expectedScreenConfigId, - storageArea: 'windowSizeAndPosition', - ), - ).thenAnswer((_) async => savedJson); - - final appWindow = AppWindow(mockStorageRepo); - await appWindow.setWindowSizeAndPosition(); - - verify( - mockStorageRepo.getValue( - _expectedScreenConfigId, - storageArea: 'windowSizeAndPosition', - ), - ).called(1); - verifyNever( - mockStorageRepo.saveValue( - key: anyNamed('key'), - value: anyNamed('value'), - ), - ); - }); - - test('when no saved rect, uses default 530x600 size', () async { - // Return null to simulate first run. - when( - mockStorageRepo.getValue(any, storageArea: anyNamed('storageArea')), - ).thenAnswer((_) async => null); - - // Track which frame was set via the channel. - List? setFrame; - _setupChannelMocks( - onSetWindowFrame: (frame) => setFrame = frame, - ); - - final appWindow = AppWindow(mockStorageRepo); - await appWindow.setWindowSizeAndPosition(); + test( + 'reads saved rect from storage and does not call saveValue', + skip: Platform.isWindows, // Not supported on Windows at the moment. + () async { + const savedJson = '{"left":100.0,"top":200.0,"width":530.0,"height":600.0}'; + when( + mockStorageRepo.getValue( + _expectedScreenConfigId, + storageArea: 'windowSizeAndPosition', + ), + ).thenAnswer((_) async => savedJson); + + final appWindow = AppWindow(mockStorageRepo); + await appWindow.setWindowSizeAndPosition(); + + verify( + mockStorageRepo.getValue( + _expectedScreenConfigId, + storageArea: 'windowSizeAndPosition', + ), + ).called(1); + verifyNever( + mockStorageRepo.saveValue( + key: anyNamed('key'), + value: anyNamed('value'), + ), + ); + }, + ); - expect(setFrame, isNotNull); - // Default width is 530, height is 600. - expect(setFrame![2], 530.0); - expect(setFrame![3], 600.0); - }); + test( + 'when no saved rect, uses default 530x600 size', + skip: Platform.isWindows, // Not supported on Windows at the moment. + () async { + // Return null to simulate first run. + when( + mockStorageRepo.getValue(any, storageArea: anyNamed('storageArea')), + ).thenAnswer((_) async => null); + + // Track which frame was set via the channel. + List? setFrame; + _setupChannelMocks( + onSetWindowFrame: (frame) => setFrame = frame, + ); + + final appWindow = AppWindow(mockStorageRepo); + await appWindow.setWindowSizeAndPosition(); + + expect(setFrame, isNotNull); + // Default width is 530, height is 600. + expect(setFrame![2], 530.0); + expect(setFrame![3], 600.0); + }, + ); }); group('reset:', () { @@ -246,22 +255,26 @@ void main() { ).called(1); }); - test('then calls setWindowSizeAndPosition (reads storage)', () async { - const savedJson = '{"left":100.0,"top":200.0,"width":530.0,"height":600.0}'; - when( - mockStorageRepo.getValue(any, storageArea: anyNamed('storageArea')), - ).thenAnswer((_) async => savedJson); - - final appWindow = AppWindow(mockStorageRepo); - await appWindow.reset(); - - verify( - mockStorageRepo.getValue( - _expectedScreenConfigId, - storageArea: 'windowSizeAndPosition', - ), - ).called(1); - }); + test( + 'then calls setWindowSizeAndPosition (reads storage)', + skip: Platform.isWindows, // Not supported on Windows at the moment. + () async { + const savedJson = '{"left":100.0,"top":200.0,"width":530.0,"height":600.0}'; + when( + mockStorageRepo.getValue(any, storageArea: anyNamed('storageArea')), + ).thenAnswer((_) async => savedJson); + + final appWindow = AppWindow(mockStorageRepo); + await appWindow.reset(); + + verify( + mockStorageRepo.getValue( + _expectedScreenConfigId, + storageArea: 'windowSizeAndPosition', + ), + ).called(1); + }, + ); }); }); }