blob: 32f5ed9e49dc51b4ec3d67325e587ff1252a59e5 [file] [log] [blame]
// Copyright 2021 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// ignore_for_file: unnecessary_lambdas
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:ermine/src/services/automator_service.dart';
import 'package:ermine/src/services/focus_service.dart';
import 'package:ermine/src/services/launch_service.dart';
import 'package:ermine/src/services/pointer_events_service.dart';
import 'package:ermine/src/services/preferences_service.dart';
import 'package:ermine/src/services/presenter_service.dart';
import 'package:ermine/src/services/shortcuts_service.dart';
import 'package:ermine/src/services/startup_service.dart';
import 'package:ermine/src/services/user_feedback_service.dart';
import 'package:ermine/src/states/app_state.dart';
import 'package:ermine/src/states/settings_state.dart';
import 'package:ermine/src/states/view_state.dart';
import 'package:ermine/src/states/view_state_impl.dart';
import 'package:ermine_utils/ermine_utils.dart';
import 'package:fidl_ermine_tools/fidl_async.dart';
import 'package:fidl/fidl.dart';
import 'package:flutter/material.dart' hide Action;
import 'package:fuchsia_inspect/inspect.dart';
import 'package:fuchsia_logger/logger.dart';
import 'package:fuchsia_scenic_flutter/fuchsia_view.dart';
import 'package:internationalization/strings.dart';
import 'package:mobx/mobx.dart';
/// Defines the implementation of [AppState].
class AppStateImpl with Disposable implements AppState {
final AutomatorService automatorService;
final FocusService focusService;
final LaunchService launchService;
final StartupService startupService;
final PresenterService presenterService;
final ShortcutsService shortcutsService;
final PreferencesService preferencesService;
final PointerEventsService pointerEventsService;
final UserFeedbackService userFeedbackService;
static const kFeedbackUrl =
'https://fuchsia.dev/fuchsia-src/contribute/report-issue';
static const kLicenseUrl =
'fuchsia-pkg://fuchsia.com/license_settings#meta/license_settings.cmx';
static const kScreenSaverUrl =
'fuchsia-pkg://fuchsia.com/screensaver#meta/screensaver.cmx';
static const kEnableUserFeedbackMarkerFile =
'/pkg/config/enable_user_feedback';
AppStateImpl({
required this.automatorService,
required this.startupService,
required this.launchService,
required this.focusService,
required this.presenterService,
required this.shortcutsService,
required this.preferencesService,
required this.pointerEventsService,
required this.userFeedbackService,
}) : _localeStream = startupService.stream.asObservable() {
launchService.onControllerClosed = _onElementClosed;
focusService.onFocusMoved = _onFocusMoved;
automatorService
..automator = _AutomatorImpl(this)
..serve(startupService.componentContext);
presenterService
..onPresenterDisposed = dispose
..onViewPresented = _onViewPresented
..onViewDismissed = _onViewDismissed
..onError = _onPresentError
..advertise(startupService.componentContext.outgoing);
// Register keyboard shortcuts and then initialize SettingsState with it.
shortcutsService.register(_actions);
settingsState = SettingsState.from(
shortcutBindings: shortcutsService.keyboardBindings,
displayDialog: _displayDialog);
pointerEventsService
..onPeekBegin = _onPeekBegin
..onPeekEnd = _onPeekEnd
..onActivity = () => startupService.onActivity('pointer');
startupService
..onInspect = _onInspect
..onIdle = _onIdle
..onAltReleased = _triggerSwitch
..onPowerBtnPressed = _onPowerBtnPressed
..serve();
userFeedbackService
..onSubmit = _onFeedbackSubmit
..onError = _onFeedbackError;
// Add reactions to state changes.
reactions
..add(reaction<bool>((_) => views.isNotEmpty, (hasViews) {
// Listen to out-of-band pointer events only when apps are launched.
pointerEventsService.listen = hasViews;
// Display overlays when no views are present.
if (!hasViews) {
overlayVisibility.value = true;
}
}))
..add(reaction<bool>((_) => isIdle, (idle) async {
if (idle) {
// Start screenSaver.
await launchService.launch('Screen Saver', kScreenSaverUrl);
}
}))
..add(reaction<Locale?>((_) => locale, (locale) {
// Removes the U extensions and T extensions
// http://www.unicode.org/reports/tr35/#36-unicode-bcp-47-u-extension
// https://www.unicode.org/reports/tr35/tr35.html#BCP47_T_Extension
_simpleLocale.value =
locale?.toString().split('-u-').first.split('-t-').first ?? '';
}));
}
@override
void dispose() {
super.dispose();
settingsState.dispose();
startupService.dispose();
focusService.dispose();
presenterService.dispose();
shortcutsService.dispose();
preferencesService.dispose();
pointerEventsService.dispose();
settingsState.dispose();
}
@override
late final SettingsState settingsState;
@override
bool get isIdle => _isIdle.value;
set isIdle(bool idle) => _isIdle.value = idle;
final Observable<bool> _isIdle = false.asObservable();
@override
ThemeData get theme => _theme.value;
late final _theme = (() {
return preferencesService.darkMode.value
? AppTheme.darkTheme
: AppTheme.lightTheme;
}).asComputed();
@override
bool get hasDarkTheme => preferencesService.darkMode.value;
/// Defines the visible status of overlays visible in 'overview' state. At the
/// moment this is the [AppBar] and the [SideBar].
final overlayVisibility = true.asObservable();
/// A flag that is set to true when the [AppBar] should be peeking.
final appBarPeeking = false.asObservable();
/// A flag that is set to true when the [SideBar] should be peeking.
final sideBarPeeking = false.asObservable();
/// A flag that is set to true when an app is launched from the app launcher.
final appIsLaunching = false.asObservable();
/// A flag that is set to true when the [UserFeedback] is triggered.
final userFeedbackVisibility = false.asObservable();
@override
bool get dialogsVisible => _dialogsVisible.value;
late final _dialogsVisible = (() {
return dialogs.isNotEmpty;
}).asComputed();
/// Returns true if shell has focus and any side bars are visible.
@override
bool get overlaysVisible => _overlaysVisible.value;
late final _overlaysVisible = (() {
return !isIdle &&
!appIsLaunching.value &&
shellHasFocus.value &&
(appBarVisible ||
sideBarVisible ||
switcherVisible ||
dialogsVisible ||
userFeedbackVisible);
}).asComputed();
@override
bool get appBarVisible => _appBarVisible.value;
late final _appBarVisible = (() {
return shellHasFocus.value &&
views.isNotEmpty &&
topView.loading &&
(appBarPeeking.value || overlayVisibility.value);
}).asComputed();
@override
bool get sideBarVisible => _sideBarVisible.value;
late final _sideBarVisible = (() {
return shellHasFocus.value &&
(sideBarPeeking.value || overlayVisibility.value);
}).asComputed();
@override
bool get userFeedbackVisible => _userFeedbackVisible.value;
late final _userFeedbackVisible = (() {
return shellHasFocus.value && userFeedbackVisibility.value;
}).asComputed();
@override
final dialogs = <DialogInfo>[].asObservable();
@override
final errors = <String, List<String>>{}.asObservable();
@override
final views = <ViewState>[].asObservable();
@override
bool get switcherVisible => _switcherVisible.value;
set switcherVisible(bool visible) => _switcherVisible.value = visible;
final Observable<bool> _switcherVisible = false.asObservable();
@override
FeedbackPage get feedbackPage => _feedbackPage.value;
set feedbackPage(FeedbackPage value) => _feedbackPage.value = value;
final _feedbackPage = Observable<FeedbackPage>(FeedbackPage.preparing);
@override
String get feedbackUuid => _feedbackUuid.value;
set feedbackUuid(String value) => _feedbackUuid.value = value;
final _feedbackUuid = ''.asObservable();
@override
String get feedbackErrorMsg => _feedbackErrorMsg.value;
set feedbackErrorMsg(String value) => _feedbackErrorMsg.value = value;
final _feedbackErrorMsg = ''.asObservable();
@override
bool get viewsVisible => _viewsVisible.value;
late final _viewsVisible = () {
return views.isNotEmpty && !isIdle;
}.asComputed();
@override
Locale? get locale => _localeStream.value;
final ObservableStream<Locale> _localeStream;
@override
String get simpleLocale => _simpleLocale.value;
final _simpleLocale = ''.asObservable();
@override
double get scale => preferencesService.scale;
late final shellHasFocus = (() {
return startupService.hostView == _focusedView.value;
}).asComputed();
@override
ViewState get topView => _topView.value;
set topView(ViewState view) => _topView.value = view;
late final Observable<ViewState> _topView =
Observable<ViewState>(views.first);
@override
ViewState? get switchTarget => _switchTarget.value;
set switchTarget(ViewState? view) => _switchTarget.value = view;
final Observable<ViewState?> _switchTarget = Observable<ViewState?>(null);
@override
String get buildVersion => startupService.buildVersion;
@override
List<Map<String, String>> get appLaunchEntries =>
startupService.appLaunchEntries;
@override
bool get isUserFeedbackEnabled => _isUserFeedbackEnabled.value;
late final _isUserFeedbackEnabled = Observable<bool>(() {
File config = File(kEnableUserFeedbackMarkerFile);
if (!config.existsSync()) {
log.info('User feedback disabled.');
return false;
}
log.info('User feedback enabled.');
return true;
}());
void setFocusToShellView() {
focusService.setFocusOnHostView();
}
void setFocusToChildView() {
if (views.isNotEmpty) {
focusService.setFocusOnView(topView);
}
}
@override
void showOverlay() => _showOverlay();
late final _showOverlay = () {
appIsLaunching.value = false;
overlayVisibility.value = true;
setFocusToShellView();
}.asAction();
@override
void hideOverlay() => _hideOverlay();
late final Action _hideOverlay = setFocusToChildView.asAction();
@override
void showAppBar() => showOverlay();
@override
void showSideBar() => showOverlay();
@override
void switchView(ViewState view) => _switchView([view]);
late final _switchView = (ViewState view) {
topView = view;
setFocusToChildView();
}.asAction();
@override
void switchNext() => _switchNext();
late final _switchNext = () {
if (views.length > 1) {
// Initialize [switchTarget] with the top view, if not already set.
switchTarget ??= topView;
// Get next view from top view. Wrap to first view in list, if it is last.
switchTarget = switchTarget == views.last
? views.first
: views[views.indexOf(switchTarget!) + 1];
// Set focus to shell view so that we can receive the final Alt key press.
setFocusToShellView();
// Display the app switcher.
switcherVisible = true;
}
}.asAction();
@override
void switchPrev() => _switchPrev();
late final _switchPrev = () {
if (views.length > 1) {
// Initialize [switchTarget] with the top view, if not already set.
switchTarget ??= topView;
switchTarget = switchTarget == views.first
? views.last
: views[views.indexOf(switchTarget!) - 1];
// Set focus to shell view so that we can receive the final Alt key press.
setFocusToShellView();
// Display the app switcher.
switcherVisible = true;
}
}.asAction();
void _triggerSwitch() {
if (switchTarget != null) {
runInAction(() {
if (switchTarget != topView) {
topView = switchTarget!;
setFocusToChildView();
}
switcherVisible = false;
switchTarget = null;
});
}
}
@override
void cancel() {
if (!dialogsVisible) {
hideOverlay();
}
// If top view is a screensaver, dismiss it.
// TODO(fxb/80131): Use cancel action associated with Esc keyboard shortcut
// to dismiss the screensaver, since mouse and keyboard input is not
// available to the shell when a child view is fullscreen.
if (views.isNotEmpty && topView.url == kScreenSaverUrl) {
_onIdle(idle: false);
}
}
@override
void closeView() => _closeView();
late final Action _closeView = () {
if (views.isEmpty) {
return;
}
appIsLaunching.value = false;
topView.close();
}.asAction();
late final Action closeAll = () {
for (final view in views) {
view.close();
}
views.clear();
}.asAction();
@override
void launch(String title, String url, {String? alternateServiceName}) =>
_launch([title, url, alternateServiceName]);
late final _launch =
(String title, String url, String? alternateServiceName) async {
try {
_clearError(url, 'ProposeElementError');
await launchService.launch(title, url,
alternateServiceName: alternateServiceName);
// Hide app launcher unless we had an error presenting the view.
if (!_isLaunchError(url)) {
runInAction(() {
appIsLaunching.value = true;
});
}
// ignore: avoid_catches_without_on_clauses
} catch (e) {
_onLaunchError(url, e.toString());
log.shout('$e: Failed to propose element <$url>');
}
}.asAction();
@override
void launchFeedback() => launch(Strings.feedback, kFeedbackUrl);
/// A flag to remember the previous overlays visibility status before showing
/// "Report an Issue" screen to set the overlays status back as it were.
///
/// Usecase 1: The user opens "Report an Issue" using the keyboard shortcut
/// while using a full-screen chromium app -> "Report an Issue"(overlay) comes
/// up on the top -> The user closes user feedback -> The full-screen chromium
/// comes back to the top.
///
/// Usecase 2: The user opens overlays(sidebar, app bar) while using a full-
/// screen chromium app -> The user clicks "Report an Issue" on Quick Settings
/// -> The user closes "Report an Issue" -> The app bar and side bar still
/// remain on top of the chromium view.
bool _wasOverlayVisible = true;
@override
void showUserFeedback() async {
if (!settingsState.dataSharingConsentEnabled) {
_displayDialog(AlertDialogInfo(
title: Strings.turnOnDataSharingTitle,
body: Strings.turnOnDataSharingBody,
actions: [Strings.close],
width: 714,
));
return;
}
runInAction(() {
// TODO(fxb/97464): Take a screenshot here when the bug is fixed.
_wasOverlayVisible = overlaysVisible;
userFeedbackVisibility.value = true;
showOverlay();
if (preferencesService.showUserFeedbackStartUpDialog.value) {
_feedbackPage.value = FeedbackPage.scrim;
_displayDialog(CheckboxDialogInfo(
body: '${Strings.firstTimeUserFeedback1}\n\n'
'${Strings.firstTimeUserFeedback2('go/workstation-feedback')}',
checkboxLabel: Strings.doNotShowAgain,
onSubmit: (value) {
runInAction(() {
if (value == true) {
preferencesService.showUserFeedbackStartUpDialog.value = false;
log.info(
'Set to not show the user feedback startup message again.');
}
_feedbackPage.value = FeedbackPage.ready;
});
},
actions: [Strings.okGotIt],
defaultAction: Strings.okGotIt,
width: 790,
));
return;
}
_feedbackPage.value = FeedbackPage.ready;
});
}
@override
void closeUserFeedback() {
runInAction(() {
userFeedbackVisibility.value = false;
if (!_wasOverlayVisible) {
hideOverlay();
}
_feedbackPage.value = FeedbackPage.preparing;
_feedbackUuid.value = '';
});
}
@override
void userFeedbackSubmit(
{required String desc,
required String username,
String title = 'New user feedback for Workstation'}) {
userFeedbackService.submit(title, desc, username);
}
@override
void setScale(double scale) => preferencesService.scale = scale;
@override
void launchLicense() => launch(Strings.license, kLicenseUrl);
@override
void setTheme({bool darkTheme = true}) => _setTheme([darkTheme]);
late final Action _setTheme = (bool darkTheme) {
preferencesService.darkMode.value = darkTheme;
}.asAction();
@override
void restart() {
_displayDialog(AlertDialogInfo(
title: Strings.confirmRestartAlertTitle,
body: Strings.confirmToSaveWorkAlertBody,
actions: [Strings.cancel, Strings.restart],
defaultAction: Strings.restart,
onAction: (action) {
if (action == Strings.restart) {
startupService.restartDevice();
// Clean up.
dispose();
}
},
));
}
@override
void shutdown() {
_displayDialog(AlertDialogInfo(
title: Strings.confirmShutdownAlertTitle,
body: Strings.confirmToSaveWorkAlertBody,
actions: [Strings.cancel, Strings.shutdown],
defaultAction: Strings.shutdown,
onAction: (action) {
if (action == Strings.shutdown) {
startupService.shutdownDevice();
// Clean up.
dispose();
}
},
));
}
@override
void logout() {
_displayDialog(AlertDialogInfo(
title: Strings.confirmLogoutAlertTitle,
body: Strings.confirmToSaveWorkAlertBody,
actions: [Strings.cancel, Strings.logout],
defaultAction: Strings.logout,
onAction: (action) {
if (action == Strings.logout) {
dispose();
startupService.logout();
}
},
));
}
@override
void checkingForUpdatesAlert() {
_displayDialog(AlertDialogInfo(
title: Strings.channelUpdateAlertTitle,
body: Strings.channelUpdateAlertBody,
actions: [Strings.close, Strings.continueLabel],
onAction: (action) {
if (action == Strings.continueLabel) {
settingsState.checkForUpdates();
}
},
));
}
@override
void dismissDialogs() {
if (appBarVisible || sideBarVisible || userFeedbackVisible) {
return;
}
hideOverlay();
}
late final showScreenSaver = () {
_onIdle(idle: true);
}.asAction();
// Map key shortcuts to corresponding actions.
Map<String, dynamic> get _actions {
final actions = {
'launcher': showOverlay,
'switchNext': switchNext,
'switchPrev': switchPrev,
'cancel': cancel,
'close': closeView,
'closeAll': closeAll,
'settings': showOverlay,
'shortcuts': () {
settingsState.showShortcutSettings();
showOverlay();
},
'screenSaver': showScreenSaver,
'inspect': () => json.encode(_getInspectData()),
'navigateBack': () {
if (!settingsState.allSettingsPageVisible) {
settingsState.showAllSettings();
}
},
'increaseBrightness': () => settingsState.increaseBrightness(),
'decreaseBrightness': () => settingsState.decreaseBrightness(),
'increaseVolume': () => settingsState.increaseVolume(),
'decreaseVolume': () => settingsState.decreaseVolume(),
'muteVolume': () => settingsState.toggleMute(),
'logout': logout,
'zoomIn': preferencesService.zoomIn,
'zoomOut': preferencesService.zoomOut,
};
if (isUserFeedbackEnabled) {
actions.addAll({'reportAnIssue': showUserFeedback});
}
return actions;
}
final _focusedView = Observable<ViewHandle?>(null);
void _onFocusMoved(ViewHandle viewHandle) {
if (_focusedView.value == viewHandle) {
return;
}
runInAction(() {
appIsLaunching.value = false;
_focusedView.value = viewHandle;
if (viewHandle == startupService.hostView) {
// Start SettingsState refresh.
settingsState.start();
} else {
overlayVisibility.value = false;
// Stop SettingsState refresh.
settingsState.stop();
// If an app view has focus, bring it to the top.
for (final view in views) {
if (viewHandle == view.view) {
topView = view;
break;
}
}
}
});
}
bool _onViewPresented(ViewState viewState) {
final view = viewState as ViewStateImpl;
// TODO(https://fxbug.dev/82840): Remove this block once this issue is
// fixed. Since the current top view looses hittesting functionality,
// explicitly reset the hittest flag on current topView before dismissing
// the overlays.
if (views.isNotEmpty) {
FuchsiaViewsService.instance
.updateView(topView.viewConnection.viewId)
.catchError((e) {
log.warning('Error calling updateView on ${topView.title}: $e');
});
}
runInAction(() {
// Make this view the top view.
views.add(view);
topView = view;
// If any, remove previously cached launch errors for the app.
if (viewState.url != null) {
_clearError(viewState.url!, 'ViewControllerEpitaph');
}
});
// Focus on view when it is loading.
view.reactions.add(reaction<bool>((_) => view.loading, (loading) {
if (loading && view == topView && view.focusable) {
setFocusToChildView();
}
}));
// Update view hittestability based on overlay visibility.
view.reactions.add(reaction<bool>(
(_) => overlaysVisible || switcherVisible || dialogsVisible, (overlay) {
// Don't reset hittest flag when showing app switcher, because the
// app switcher does not react to pointer events.
view.hitTestable = !overlay;
}));
// Remove view from views when it is closed.
view.reactions.add(when((_) => view.closed, () {
_onViewDismissed(view);
}));
return true;
}
// Called when a view is dismissed from an external source (not user).
void _onViewDismissed(ViewState viewState) {
runInAction(() {
final view = viewState as ViewStateImpl;
// Switch to previous view before closing this view if it was the top view
// and there are other views.
if (view == topView && views.length > 1) {
final prevView = view != views.first
? views[views.indexOf(topView) - 1]
: views.last;
topView = prevView;
setFocusToChildView();
}
views.remove(view);
view.dispose();
});
}
void _onPeekBegin(PeekEdge edge) {
runInAction(() {
appBarPeeking.value = edge == PeekEdge.left;
sideBarPeeking.value = edge == PeekEdge.right;
setFocusToShellView();
});
}
void _onPeekEnd() {
runInAction(() {
appBarPeeking.value = false;
sideBarPeeking.value = false;
if (!overlayVisibility.value) {
setFocusToChildView();
}
});
}
void _onPresentError(String url, String error) {
runInAction(() {
final errorSpec = error.split('.').last;
final description = errorSpec == 'invalidViewSpec'
? Strings.invalidViewSpecDesc
: errorSpec == 'rejected'
? Strings.viewPresentRejectedDesc
: Strings.defaultPresentErrorDesc;
const referenceLink =
'https://fuchsia.dev/reference/fidl/fuchsia.element#GraphicalPresenter.PresentView';
if (_isPrelistedApp(url)) {
errors[url] = [description, '$error\n$referenceLink'];
} else {
_displayDialog(AlertDialogInfo(
title: description,
body: '${Strings.errorWhilePresenting},\n$url\n\n'
'${Strings.errorType}: $error\n\n'
'${Strings.moreErrorInformation}\n$referenceLink',
actions: [Strings.close],
));
}
});
}
void _onLaunchError(String url, String error) {
final proposeError = error.split(' ').last;
print('Handling launch error $proposeError for $url');
final errorSpec = proposeError.split('.').last;
final description = errorSpec == 'notFound'
? Strings.urlNotFoundDesc
: errorSpec == 'rejected'
? Strings.launchRejectedDesc
: Strings.defaultProposeErrorDesc;
const referenceLink =
'https://fuchsia.dev/reference/fidl/fuchsia.element#Manager.ProposeElement';
errors[url] = [
description,
'$proposeError\n\n${Strings.moreErrorInformation}\n$referenceLink'
];
}
void _onPowerBtnPressed() {
_displayDialog(AlertDialogInfo(
title: Strings.restartOrShutDown,
body: Strings.powerBtnPressedDesc,
width: 648,
actions: [Strings.cancel, Strings.restart, Strings.shutdown],
onAction: (action) {
if (action == Strings.restart) {
startupService.restartDevice();
dispose();
}
if (action == Strings.shutdown) {
startupService.shutdownDevice();
dispose();
}
},
));
}
bool _isPrelistedApp(String url) =>
appLaunchEntries.any((entry) => entry['url'] == url);
bool _isLaunchError(String url) => errors[url] != null;
void _clearError(String url, String errorType) {
runInAction(() {
errors.removeWhere(
(key, value) => key == url && value[1].startsWith(errorType));
});
}
void _onIdle({required bool idle}) => runInAction(() {
if (preferencesService.showScreensaver) {
if (idle) {
isIdle = idle;
} else {
// Wait for the screen saver to be visible and running before closing
// it.
if (views.isNotEmpty &&
topView.url == kScreenSaverUrl &&
topView.loading) {
closeView();
isIdle = false;
}
}
}
});
// This callback is triggered only for the views launched from the shell.
void _onElementClosed(String id) {
// Find the view with id that was terminated without closing its view. There
// two scenarios where this can happen:
// - VIEW_LOADED: The underlying component self exited or was killed from
// terminal or it crashed AFTER the view was loaded. Currently there is no
// way to distinguish the actual reason. Filed https://fxbug.dev/83165
// - VIEW_NOT_LOADED: The view did not load because the component url is
// invalid. Ideally, this would be handled by ElementController throwing
// a [ProposeElementError.NOT_FOUND].
// TODO(https://fxbug.dev/83164): Handle ProposeElementError once
// implemented.
final stuckViews = views.where((view) => view.id == id);
if (stuckViews.isNotEmpty) {
final view = stuckViews.first;
if (view.loaded) {
view.close();
} else {
runInAction(() => appIsLaunching.value = false);
final description = Strings.applicationFailedToStart(view.title);
_displayDialog(AlertDialogInfo(
title: description,
body: 'Url: ${view.url}',
defaultAction: Strings.close,
actions: [Strings.close],
onClose: view.close,
));
}
}
}
Map<String, dynamic> _getInspectData() {
final data = <String, dynamic>{};
// Overlays currently visible.
data['appBarVisible'] = appBarVisible;
data['sideBarVisible'] = sideBarVisible;
data['overlaysVisible'] = overlaysVisible;
data['lastAction'] = shortcutsService.lastShortcutAction;
data['darkMode'] = hasDarkTheme;
// Number of running component views.
data['numViews'] = views.length;
if (views.isNotEmpty) {
// List of views that are currently running.
for (int i = 0; i < views.length; i++) {
final view = views[i];
// Active (focused) view.
if (view == topView) {
data['activeView'] = i;
}
// View title, url, focused and viewport.
data['view-$i'] = <String, dynamic>{};
final viewData = data['view-$i'];
viewData['title'] = view.title;
viewData['url'] = view.url;
viewData['focused'] = view.view == _focusedView.value;
final viewport = view.viewport;
if (viewport != null) {
viewData['viewportLTRB'] = [
viewport.left,
viewport.top,
viewport.right,
viewport.bottom,
].join(',');
}
}
}
return data;
}
void _displayDialog(DialogInfo dialog) {
runInAction(() {
// Override the onClose method to allow removal of dialog from dialogs.
final closeFn = dialog.onClose;
dialog.onClose = () {
closeFn?.call();
runInAction(() => dialogs.remove(dialog));
};
dialogs.add(dialog);
if (viewsVisible) {
// Set focus to shell view so that we can receive the esc key press.
setFocusToShellView();
}
});
}
// Adds inspect data when requested by [Inspect].
void _onInspect(Node node, [Map<String, dynamic>? inspectData]) {
final data = inspectData ?? _getInspectData();
for (final entry in data.entries) {
if (entry.value is Map<String, dynamic>) {
_onInspect(node.child(entry.key)!, entry.value);
} else {
switch (entry.value.runtimeType) {
case bool:
node.boolProperty(entry.key)!.setValue(entry.value);
break;
case int:
node.intProperty(entry.key)!.setValue(entry.value);
break;
case double:
node.doubleProperty(entry.key)!.setValue(entry.value);
break;
case String:
node.stringProperty(entry.key)!.setValue(entry.value);
break;
default:
assert(false, 'Invalid inspect type: ${entry.value.runtimeType}');
break;
}
}
}
}
void _onFeedbackSubmit(String uuid) {
runInAction(() {
_feedbackUuid.value = uuid;
_feedbackPage.value = FeedbackPage.submitted;
});
}
void _onFeedbackError(String error) {
runInAction(() {
_feedbackErrorMsg.value = error;
_feedbackPage.value = FeedbackPage.failed;
});
}
}
class _AutomatorImpl implements Automator {
final AppStateImpl state;
_AutomatorImpl(this.state);
@override
Future<void> launch(String appName) async {
final entry = state.appLaunchEntries
.firstWhereOrNull((entry) => entry['title'] == appName);
if (entry == null) {
throw MethodException(AutomatorErrorCode.invalidArgs);
}
state.launch(entry['title']!, entry['url']!,
alternateServiceName: entry['element_manager_name']);
// Wait for the app to launch and receive focus.
final completer = Completer();
late ReactionDisposer disposer;
disposer = reaction<ViewHandle?>((_) => state._focusedView.value, (view) {
// Check if a child view received focus and it is the launched view.
if (!state.shellHasFocus.value && state.topView.title == entry['title']) {
completer.complete();
disposer();
}
});
// If the app does not launch in a reasonable time, throw failed exception.
Future.delayed(
Duration(seconds: 30),
(() => completer
.completeError(MethodException(AutomatorErrorCode.failed))));
return completer.future;
}
@override
Future<void> closeAll() async {
state.closeAll();
}
}