diff --git a/README.md b/README.md
index 2135b6be..05787a51 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,22 @@ A [Hacker News](https://news.ycombinator.com/) client built with Flutter.
- [x] [Accent color](#thread) and [font customization](#accent-color-and-font-customization)
- [x] And more...
+## Desktop Support (Linux, macOS, Windows)
+
+Hacki can be built and run on desktop platforms. The primary motivation is Linux (including Arch Linux via the [AUR package](https://aur.archlinux.org/packages/hacki)), though macOS and Windows also benefit from the same changes.
+
+Desktop builds are left to the reader/packager — no official distribution is provided for desktop at this time.
+
+**Limitations on desktop — the following features are unavailable:**
+
+| Feature | Limitation |
+|---------|-----------|
+| Offline reading (WebView) | Not supported on Linux; links in offline mode are silently skipped |
+| Favorites import via QR code | Camera scanning unavailable; use the **From File** option instead |
+| Share extension | System share intent is mobile-only |
+| In-app bug report email | Use the **GitHub** option in the bug report dialog instead |
+| Custom haptics | No-op on desktop |
+
## Home page and story tile customization
diff --git a/lib/screens/home/home_screen.dart b/lib/screens/home/home_screen.dart
index d839e590..de963c9f 100644
--- a/lib/screens/home/home_screen.dart
+++ b/lib/screens/home/home_screen.dart
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
+import 'dart:io';
import 'package:app_links/app_links.dart';
import 'package:feature_discovery/feature_discovery.dart';
@@ -36,8 +37,7 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State
with SingleTickerProviderStateMixin, RouteAware, ItemActionMixin, Loggable {
late final TabController tabController;
- late final StreamSubscription>
- intentDataStreamSubscription;
+ StreamSubscription>? intentDataStreamSubscription;
late final StreamSubscription notificationStreamSubscription;
late final StreamSubscription siriSuggestionStreamSubscription;
late final StreamSubscription
@@ -68,13 +68,15 @@ class _HomeScreenState extends State
}
});
- ReceiveSharingIntent.instance.getInitialMedia().then(
- onShareExtensionTapped,
- );
+ if (Platform.isAndroid || Platform.isIOS) {
+ ReceiveSharingIntent.instance.getInitialMedia().then(
+ onShareExtensionTapped,
+ );
- intentDataStreamSubscription = ReceiveSharingIntent.instance
- .getMediaStream()
- .listen(onShareExtensionTapped);
+ intentDataStreamSubscription = ReceiveSharingIntent.instance
+ .getMediaStream()
+ .listen(onShareExtensionTapped);
+ }
if (!selectNotificationSubject.hasListener) {
notificationStreamSubscription = selectNotificationSubject.stream.listen(
@@ -107,7 +109,7 @@ class _HomeScreenState extends State
@override
void dispose() {
tabController.dispose();
- intentDataStreamSubscription.cancel();
+ intentDataStreamSubscription?.cancel();
notificationStreamSubscription.cancel();
siriSuggestionStreamSubscription.cancel();
downloadStreamSubscription.cancel();
diff --git a/lib/screens/item/item_screen.dart b/lib/screens/item/item_screen.dart
index ef3e697b..1eef2cf2 100644
--- a/lib/screens/item/item_screen.dart
+++ b/lib/screens/item/item_screen.dart
@@ -1,4 +1,5 @@
import 'dart:async';
+import 'dart:io';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
@@ -230,7 +231,10 @@ class _ItemScreenState extends State
.state
.isOfflineReading;
final bool shouldShowWebViewBottomSheet =
- !isOfflineReading && widget.item is Story && widget.item.url.isNotEmpty;
+ !isOfflineReading &&
+ widget.item is Story &&
+ widget.item.url.isNotEmpty &&
+ !Platform.isLinux;
return MultiBlocListener(
listeners: >[
BlocListener(
diff --git a/lib/screens/profile/qr_code_scanner_screen.dart b/lib/screens/profile/qr_code_scanner_screen.dart
index ce3a9d7f..3a468d59 100644
--- a/lib/screens/profile/qr_code_scanner_screen.dart
+++ b/lib/screens/profile/qr_code_scanner_screen.dart
@@ -1,3 +1,5 @@
+import 'dart:io';
+
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/styles/styles.dart';
@@ -40,13 +42,15 @@ class _QrCodeScannerScreenState extends State {
],
),
extendBodyBehindAppBar: true,
- body: Column(
- children: [
- Expanded(
- child: QRView(key: qrKey, onQRViewCreated: onQRViewCreated),
- ),
- ],
- ),
+ body: (Platform.isAndroid || Platform.isIOS)
+ ? Column(
+ children: [
+ Expanded(
+ child: QRView(key: qrKey, onQRViewCreated: onQRViewCreated),
+ ),
+ ],
+ )
+ : const Center(child: Text('QR scanning is not supported on this platform.')),
);
}
diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart
index d4578446..9ef2f61a 100644
--- a/lib/screens/settings/settings_screen.dart
+++ b/lib/screens/settings/settings_screen.dart
@@ -811,16 +811,17 @@ class _SettingsViewState extends State
return AlertDialog(
actionsPadding: const EdgeInsets.all(Dimens.pt16),
actions: [
- ElevatedButton(
- onPressed: onSendEmailTapped,
- child: const Row(
- children: [
- Icon(Icons.email),
- SizedBox(width: Dimens.pt12),
- Text('Email'),
- ],
+ if (Platform.isAndroid || Platform.isIOS)
+ ElevatedButton(
+ onPressed: onSendEmailTapped,
+ child: const Row(
+ children: [
+ Icon(Icons.email),
+ SizedBox(width: Dimens.pt12),
+ Text('Email'),
+ ],
+ ),
),
- ),
ElevatedButton(
onPressed: () => onGithubTapped(context.rect),
child: const Row(
@@ -991,11 +992,12 @@ class _SettingsViewState extends State
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
- ListTile(
- leading: const Icon(Icons.qr_code_scanner),
- title: const Text('QR Code'),
- onTap: () => context.pop(ImportSource.qrCode),
- ),
+ if (Platform.isAndroid || Platform.isIOS)
+ ListTile(
+ leading: const Icon(Icons.qr_code_scanner),
+ title: const Text('QR Code'),
+ onTap: () => context.pop(ImportSource.qrCode),
+ ),
ListTile(
leading: const Icon(Icons.file_open_outlined),
title: const Text('From File'),
diff --git a/lib/screens/settings/widgets/offline_list_tile.dart b/lib/screens/settings/widgets/offline_list_tile.dart
index f87f9e44..fa37bf28 100644
--- a/lib/screens/settings/widgets/offline_list_tile.dart
+++ b/lib/screens/settings/widgets/offline_list_tile.dart
@@ -1,3 +1,5 @@
+import 'dart:io';
+
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -21,7 +23,7 @@ class OfflineListTile extends StatelessWidget {
listener: (BuildContext context, StoriesState state) {
if (state.downloadStatus == StoriesDownloadStatus.failure ||
state.downloadStatus == StoriesDownloadStatus.finished) {
- WakelockPlus.disable();
+ if (!Platform.isLinux) WakelockPlus.disable();
}
},
buildWhen: (StoriesState previous, StoriesState current) =>
@@ -161,7 +163,7 @@ class OfflineListTile extends StatelessWidget {
),
).then((bool? includeWebPage) {
if (includeWebPage != null) {
- WakelockPlus.enable();
+ if (!Platform.isLinux) WakelockPlus.enable();
storiesBloc.add(StoriesDownload(includingWebPage: includeWebPage));
}
diff --git a/lib/services/dialog_proxy.dart b/lib/services/dialog_proxy.dart
index e6500332..c228ff1f 100644
--- a/lib/services/dialog_proxy.dart
+++ b/lib/services/dialog_proxy.dart
@@ -1,3 +1,5 @@
+import 'dart:io';
+
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@@ -109,7 +111,7 @@ abstract final class DialogProxy {
),
).then((bool? abortDownloading) {
if (abortDownloading ?? false) {
- WakelockPlus.enable();
+ if (!Platform.isLinux) WakelockPlus.enable();
if (context != null && context.mounted) {
context.read().add(StoriesCancelDownload());
diff --git a/lib/utils/link_utils.dart b/lib/utils/link_utils.dart
index 5740896c..b62a483e 100644
--- a/lib/utils/link_utils.dart
+++ b/lib/utils/link_utils.dart
@@ -29,13 +29,15 @@ abstract final class LinkUtils {
bool shouldUseHackiForHnLink = true,
}) {
if (isOfflineReading) {
- locator.get().hasCachedWebPage(url: link).then((
- bool cached,
- ) {
- if (cached) {
- router.push(Paths.webView.landing, extra: link);
- }
- });
+ if (!Platform.isLinux) {
+ locator.get().hasCachedWebPage(url: link).then((
+ bool cached,
+ ) {
+ if (cached) {
+ router.push(Paths.webView.landing, extra: link);
+ }
+ });
+ }
return;
}
@@ -63,6 +65,8 @@ abstract final class LinkUtils {
context.read().state.isCustomTabEnabled ==
false) {
launchUrl(uri, mode: LaunchMode.externalApplication);
+ } else if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
+ launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
final Color primaryColor = Theme.of(context).colorScheme.primary;
_browser