mirror of
https://github.com/localsend/localsend.git
synced 2026-06-23 04:10:07 +00:00
feat(macos): handle files that were dropped into the app icon (#1471)
This commit is contained in:
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user