feat(macos): handle files that were dropped into the app icon (#1471)

This commit is contained in:
Tien Do Nam
2024-07-10 19:28:08 +02:00
committed by GitHub
parent 887770b2d7
commit fc6fcd8418
12 changed files with 202 additions and 63 deletions
+1
View File
@@ -8,6 +8,7 @@
- feat(desktop): make auto start + start hidden more stable, now listens to `--hidden` parameter instead of `autostart` (@Tienisto)
- feat(desktop): load initial files from command line arguments (@Tienisto)
- feat(desktop): show progress in the taskbar (@NightFeather0615)
- feat(macos): handle files that were dropped into the app icon (@Tienisto)
- feat: add discovery timeout setting for advanced users (@o2e)
- fix: sanitize file names with invalid characters (@Caesarovich)
- fix: UI overflow when window height is too small (@CHUNG-HAO)
+25 -13
View File
@@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:localsend_app/pages/home_page.dart';
import 'package:localsend_app/pages/home_page_controller.dart';
import 'package:localsend_app/provider/animation_provider.dart';
import 'package:localsend_app/provider/app_arguments_provider.dart';
import 'package:localsend_app/provider/device_info_provider.dart';
@@ -33,6 +34,7 @@ import 'package:localsend_app/util/native/cache_helper.dart';
import 'package:localsend_app/util/native/context_menu_helper.dart';
import 'package:localsend_app/util/native/cross_file_converters.dart';
import 'package:localsend_app/util/native/device_info_helper.dart';
import 'package:localsend_app/util/native/open_file_receiver.dart';
import 'package:localsend_app/util/native/platform_check.dart';
import 'package:localsend_app/util/native/tray_helper.dart';
import 'package:localsend_app/util/ui/dynamic_colors.dart';
@@ -127,7 +129,7 @@ Future<RefenaContainer> preInit(List<String> args) async {
StreamSubscription? _sharedMediaSubscription;
/// Will be called when home page has been initialized
Future<void> postInit(BuildContext context, Ref ref, bool appStart, void Function(int) goToPage) async {
Future<void> postInit(BuildContext context, Ref ref, bool appStart) async {
await updateSystemOverlayStyle(context);
if (checkPlatform([TargetPlatform.android])) {
@@ -154,10 +156,26 @@ Future<void> postInit(BuildContext context, Ref ref, bool appStart, void Functio
if (appStart) {
final args = ref.read(appArgumentsProvider);
await ref.global.dispatchAsync(_HandleAppStartArgumentsAction(
args: args,
goToPage: goToPage,
));
if (defaultTargetPlatform == TargetPlatform.macOS) {
final files = await getOpenedFiles();
if (files.isNotEmpty) {
await ref.global.dispatchAsync(_HandleAppStartArgumentsAction(
args: files,
));
}
// handle future dropped files
getOpenedFilesStream().listen((files) {
ref.global.dispatchAsync(_HandleAppStartArgumentsAction(
args: files,
));
});
} else {
await ref.global.dispatchAsync(_HandleAppStartArgumentsAction(
args: args,
));
}
}
bool hasInitialShare = false;
@@ -172,7 +190,6 @@ Future<void> postInit(BuildContext context, Ref ref, bool appStart, void Functio
// ignore: unawaited_futures
ref.global.dispatchAsync(_HandleShareIntentAction(
payload: initialSharedPayload,
goToPage: goToPage,
));
}
}
@@ -181,7 +198,6 @@ Future<void> postInit(BuildContext context, Ref ref, bool appStart, void Functio
_sharedMediaSubscription = shareHandler.sharedMediaStream.listen((SharedMedia payload) {
ref.global.dispatchAsync(_HandleShareIntentAction(
payload: payload,
goToPage: goToPage,
));
});
}
@@ -202,11 +218,9 @@ Future<void> postInit(BuildContext context, Ref ref, bool appStart, void Functio
class _HandleShareIntentAction extends AsyncGlobalAction {
final SharedMedia payload;
final void Function(int) goToPage;
_HandleShareIntentAction({
required this.payload,
required this.goToPage,
});
@override
@@ -220,24 +234,22 @@ class _HandleShareIntentAction extends AsyncGlobalAction {
converter: CrossFileConverters.convertSharedAttachment,
));
goToPage(HomeTab.send.index);
ref.redux(homePageControllerProvider).dispatch(ChangeTabAction(HomeTab.send));
}
}
class _HandleAppStartArgumentsAction extends AsyncGlobalAction {
final List<String> args;
final void Function(int) goToPage;
_HandleAppStartArgumentsAction({
required this.args,
required this.goToPage,
});
@override
Future<void> reduce() async {
final filesAdded = await ref.redux(selectedSendingFilesProvider).dispatchAsyncTakeResult(LoadSelectionFromArgsAction(args));
if (filesAdded) {
goToPage(HomeTab.send.index);
ref.redux(homePageControllerProvider).dispatch(ChangeTabAction(HomeTab.send));
}
}
}
+14
View File
@@ -7,6 +7,7 @@ import 'package:localsend_app/pages/debug/http_logs_page.dart';
import 'package:localsend_app/pages/debug/security_debug_page.dart';
import 'package:localsend_app/provider/app_arguments_provider.dart';
import 'package:localsend_app/provider/persistence_provider.dart';
import 'package:localsend_app/util/native/open_file_receiver.dart';
import 'package:localsend_app/widget/debug_entry.dart';
import 'package:refena_flutter/refena_flutter.dart';
import 'package:routerino/routerino.dart';
@@ -41,6 +42,19 @@ class DebugPage extends StatelessWidget {
name: 'App Arguments',
value: appArguments.isEmpty ? null : appArguments.map((e) => '"$e"').join(' '),
),
if (defaultTargetPlatform == TargetPlatform.macOS)
FutureBuilder(
// ignore: discarded_futures
future: getOpenedFiles().onError((error, stackTrace) async {
return <String>[error.toString()];
}),
builder: (context, snapshot) {
return DebugEntry(
name: 'Opened Files',
value: snapshot.hasData ? snapshot.data.toString() : 'Loading...',
);
},
),
DebugEntry(
name: 'Dart SDK',
value: Platform.version,
+11 -24
View File
@@ -4,11 +4,11 @@ import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import 'package:localsend_app/gen/strings.g.dart';
import 'package:localsend_app/init.dart';
import 'package:localsend_app/pages/home_page_controller.dart';
import 'package:localsend_app/pages/tabs/receive_tab.dart';
import 'package:localsend_app/pages/tabs/send_tab.dart';
import 'package:localsend_app/pages/tabs/settings_tab.dart';
import 'package:localsend_app/provider/selection/selected_sending_files_provider.dart';
import 'package:localsend_app/provider/ui/home_tab_provider.dart';
import 'package:localsend_app/theme.dart';
import 'package:localsend_app/util/native/cross_file_converters.dart';
import 'package:localsend_app/widget/responsive_builder.dart';
@@ -53,36 +53,23 @@ class HomePage extends StatefulWidget {
}
class _HomePageState extends State<HomePage> with Refena {
late PageController _pageController;
HomeTab _currentTab = HomeTab.receive;
bool _dragAndDropIndicator = false;
@override
void initState() {
super.initState();
_pageController = PageController(initialPage: widget.initialTab.index);
_currentTab = widget.initialTab;
ensureRef((ref) async {
ref.redux(homeTabProvider).dispatch(SetHomeTabAction(widget.initialTab));
await postInit(context, ref, widget.appStart, _goToPage);
});
}
void _goToPage(int index) {
final tab = HomeTab.values[index];
ref.redux(homeTabProvider).dispatch(SetHomeTabAction(tab));
setState(() {
_currentTab = tab;
_pageController.jumpToPage(_currentTab.index);
ref.redux(homePageControllerProvider).dispatch(ChangeTabAction(widget.initialTab));
await postInit(context, ref, widget.appStart);
});
}
@override
Widget build(BuildContext context) {
Translations.of(context); // rebuild on locale change
final vm = context.watch(homePageControllerProvider);
return DropTarget(
onDragEntered: (_) {
setState(() {
@@ -105,7 +92,7 @@ class _HomePageState extends State<HomePage> with Refena {
converter: CrossFileConverters.convertXFile,
));
}
_goToPage(HomeTab.send.index);
vm.changeTab(HomeTab.send);
},
child: ResponsiveBuilder(
builder: (sizingInformation) {
@@ -114,8 +101,8 @@ class _HomePageState extends State<HomePage> with Refena {
children: [
if (!sizingInformation.isMobile)
NavigationRail(
selectedIndex: _currentTab.index,
onDestinationSelected: _goToPage,
selectedIndex: vm.currentTab.index,
onDestinationSelected: (index) => vm.changeTab(HomeTab.values[index]),
extended: sizingInformation.isDesktop,
backgroundColor: Theme.of(context).cardColorWithElevation,
leading: sizingInformation.isDesktop
@@ -144,7 +131,7 @@ class _HomePageState extends State<HomePage> with Refena {
child: Stack(
children: [
PageView(
controller: _pageController,
controller: vm.controller,
physics: const NeverScrollableScrollPhysics(),
children: const [
ReceiveTab(),
@@ -175,8 +162,8 @@ class _HomePageState extends State<HomePage> with Refena {
),
bottomNavigationBar: sizingInformation.isMobile
? NavigationBar(
selectedIndex: _currentTab.index,
onDestinationSelected: _goToPage,
selectedIndex: vm.currentTab.index,
onDestinationSelected: (index) => vm.changeTab(HomeTab.values[index]),
destinations: HomeTab.values.map((tab) {
return NavigationDestination(icon: Icon(tab.icon), label: tab.label);
}).toList(),
+46
View File
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:localsend_app/pages/home_page.dart';
import 'package:refena_flutter/refena_flutter.dart';
class HomePageVm {
final PageController controller;
final HomeTab currentTab;
final void Function(HomeTab) changeTab;
HomePageVm({
required this.controller,
required this.currentTab,
required this.changeTab,
});
}
final homePageControllerProvider = ReduxProvider<HomePageController, HomePageVm>(
(ref) => HomePageController(),
);
class HomePageController extends ReduxNotifier<HomePageVm> {
@override
HomePageVm init() {
return HomePageVm(
controller: PageController(),
currentTab: HomeTab.receive,
changeTab: (tab) => redux.dispatch(ChangeTabAction(tab)),
);
}
}
class ChangeTabAction extends ReduxAction<HomePageController, HomePageVm> {
final HomeTab tab;
ChangeTabAction(this.tab);
@override
HomePageVm reduce() {
state.controller.jumpToPage(tab.index);
return HomePageVm(
controller: state.controller,
currentTab: tab,
changeTab: state.changeTab,
);
}
}
+2 -2
View File
@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:localsend_app/gen/strings.g.dart';
import 'package:localsend_app/pages/home_page.dart';
import 'package:localsend_app/pages/home_page_controller.dart';
import 'package:localsend_app/pages/receive_history_page.dart';
import 'package:localsend_app/pages/tabs/receive_tab_vm.dart';
import 'package:localsend_app/provider/animation_provider.dart';
import 'package:localsend_app/provider/ui/home_tab_provider.dart';
import 'package:localsend_app/util/ip_helper.dart';
import 'package:localsend_app/widget/animations/initial_fade_transition.dart';
import 'package:localsend_app/widget/custom_icon_button.dart';
@@ -42,7 +42,7 @@ class ReceiveTab extends StatelessWidget {
delay: const Duration(milliseconds: 200),
child: Consumer(builder: (context, ref) {
final animations = ref.watch(animationProvider);
final activeTab = ref.watch(homeTabProvider);
final activeTab = ref.watch(homePageControllerProvider.select((state) => state.currentTab));
return RotatingWidget(
duration: const Duration(seconds: 15),
spinning: vm.serverState != null && animations && activeTab == HomeTab.receive,
@@ -8,6 +8,7 @@ import 'package:localsend_app/model/state/send/send_session_state.dart';
import 'package:localsend_app/model/state/server/receive_session_state.dart';
import 'package:localsend_app/model/state/server/receiving_file.dart';
import 'package:localsend_app/pages/home_page.dart';
import 'package:localsend_app/pages/home_page_controller.dart';
import 'package:localsend_app/pages/progress_page.dart';
import 'package:localsend_app/pages/receive_page.dart';
import 'package:localsend_app/provider/device_info_provider.dart';
@@ -588,16 +589,17 @@ class ReceiveController {
});
// ignore: discarded_futures
request.readAsString().then((body) {
request.readAsString().then((body) async {
if (body.isEmpty) {
return;
}
final Map<String, dynamic> jsonBody = jsonDecode(body);
final List<String> args = (jsonBody['args'] as List?)?.cast<String>() ?? <String>[];
// ignore: discarded_futures
server.ref.redux(selectedSendingFilesProvider).dispatchAsync(LoadSelectionFromArgsAction(args));
final filesAdded = await server.ref.redux(selectedSendingFilesProvider).dispatchAsyncTakeResult(LoadSelectionFromArgsAction(args));
if (filesAdded) {
server.ref.redux(homePageControllerProvider).dispatch(ChangeTabAction(HomeTab.send));
}
});
return server.responseJson(200);
@@ -1,20 +0,0 @@
import 'package:localsend_app/pages/home_page.dart';
import 'package:refena_flutter/refena_flutter.dart';
/// This provider is used so that tabs know if they are currently visible.
/// The [HomePage] is responsible for setting the current tab.
final homeTabProvider = ReduxProvider<HomeTabNotifier, HomeTab>((ref) => HomeTabNotifier());
class HomeTabNotifier extends ReduxNotifier<HomeTab> {
@override
HomeTab init() => HomeTab.receive;
}
class SetHomeTabAction extends ReduxAction<HomeTabNotifier, HomeTab> {
final HomeTab tab;
SetHomeTabAction(this.tab);
@override
HomeTab reduce() => tab;
}
@@ -0,0 +1,41 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
const _methodChannel = MethodChannel('main-delegate-channel');
/// Returns a list of files the app was opened with.
/// This happens:
/// - on macOS when files are dropped onto the app icon
/// - on macOS when files are opened with the app via Finder
Future<List<String>> getOpenedFiles() async {
if (defaultTargetPlatform != TargetPlatform.macOS) {
return <String>[];
}
final files = await _methodChannel.invokeMethod<List>('getFiles');
if (files == null) {
throw PlatformException(
code: 'UNKNOWN',
message: 'Unable to get files',
);
}
return files.cast<String>();
}
Stream<List<String>> getOpenedFilesStream() {
if (defaultTargetPlatform != TargetPlatform.macOS) {
return Stream.value(<String>[]).asBroadcastStream();
}
final controller = StreamController<List<String>>();
_methodChannel.setMethodCallHandler((call) async {
if (call.method == 'onFiles') {
controller.add((call.arguments as List).cast<String>());
}
});
return controller.stream.asBroadcastStream();
}
+36
View File
@@ -7,4 +7,40 @@ class AppDelegate: FlutterAppDelegate {
// LocalSend handles the close event manually
return false
}
// START: method channel logic
private var channel: FlutterMethodChannel?
private var cachedFiles: [String]? = []
override func applicationDidFinishLaunching(_ notification: Notification) {
let controller = mainFlutterWindow?.contentViewController as! FlutterViewController
channel = FlutterMethodChannel(name: "main-delegate-channel", binaryMessenger: controller.engine.binaryMessenger)
channel?.setMethodCallHandler(handle)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "getFiles":
result(cachedFiles ?? [])
cachedFiles = nil // files has been fetched, no need to cache anymore
default:
result(FlutterMethodNotImplemented)
}
}
// END: method channel logic
// START: handle opened files
override func application(_ sender: NSApplication, openFile filename: String) -> Bool {
cachedFiles?.append(filename)
channel?.invokeMethod("onFiles", arguments: [filename])
return true
}
override func application(_ sender: NSApplication, openFiles filenames: [String]) {
cachedFiles?.append(contentsOf: filenames)
channel?.invokeMethod("onFiles", arguments: filenames)
}
// END: handle opened files
}
+19
View File
@@ -96,5 +96,24 @@
<string>_bonjour._tcp</string>
<string>_lnp._tcp.</string>
</array>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>*</string>
</array>
<key>CFBundleTypeName</key>
<string>Any File</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSItemContentTypes</key>
<array>
<string>public.data</string>
<string>public.content</string>
</array>
</dict>
</array>
</dict>
</plist>
+1
View File
@@ -19,6 +19,7 @@ codesign --deep --force --verbose --options runtime --sign "$SIGN_ID" build/maco
echo
echo "Creating dmg..."
echo
rm -f LocalSend.dmg
create-dmg \
--volname "LocalSend" \
--window-size 500 300 \