Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.44.1
cache: true

- name: Cache pub dependencies
Expand Down Expand Up @@ -129,6 +130,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.44.1
cache: true

- name: Cache pub dependencies
Expand Down Expand Up @@ -184,6 +186,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.44.1
cache: true

- name: Cache pub dependencies
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release-mac.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.44.1
cache: true

- name: Cache pub dependencies
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release-win.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.44.1

- name: Install Fastforge
run: dart pub global activate fastforge
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Miscellaneous
*.class
*.lock
# Applications must pin their dependency graph: without this, every release is
# built against whatever `stable` resolves to that day, which makes a runtime
# regression impossible to bisect.
!pubspec.lock
*.log
*.pyc
*.swp
Expand Down
4 changes: 4 additions & 0 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ All data is stored locally under your user profile.
| Browsers | `%LOCALAPPDATA%\LinkUnbound\browsers.json` |
| Rules | `%LOCALAPPDATA%\LinkUnbound\rules.json` |
| Log | `%LOCALAPPDATA%\LinkUnbound\navigate.log` |
| Crash log | `%LOCALAPPDATA%\LinkUnbound\startup_crash.log` |
| Icons | `%LOCALAPPDATA%\LinkUnbound\icons\` |

**macOS** — `~/Library/Application Support/LinkUnbound/`:
Expand All @@ -83,6 +84,7 @@ All data is stored locally under your user profile.
| Browsers | `~/Library/Application Support/LinkUnbound/browsers.json` |
| Rules | `~/Library/Application Support/LinkUnbound/rules.json` |
| Log | `~/Library/Application Support/LinkUnbound/navigate.log` |
| Crash log | `~/Library/Application Support/LinkUnbound/startup_crash.log` |
| Icons | `~/Library/Application Support/LinkUnbound/icons/` |

These folders are protected by your operating system's user account permissions. Other users on the same computer cannot access them under normal conditions.
Expand Down Expand Up @@ -197,6 +199,8 @@ URLs are redacted **at write time** — before they ever reach the log file on d

This means the `navigate.log` file on your machine never contains real URLs. The diagnostics export simply copies the last 200 lines of this already-redacted log.

Redaction covers the whole log record, including attached error objects and stack traces. This matters because a failed browser launch raises an error whose text embeds the full command line — that is, the URL. The same redaction is applied to `startup_crash.log`, a separate file written only when the app fails during startup; it is capped in size and is safe to delete at any time.

---

## Children's Privacy
Expand Down
95 changes: 67 additions & 28 deletions apps/linkunbound/lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,16 @@ final class NavigateApp extends ConsumerStatefulWidget {

final class _NavigateAppState extends ConsumerState<NavigateApp>
with WindowListener {
// Guards against blur events fired before the picker has settled.
// Set to true either on the first onWindowFocus after showing, or after a
// fallback timer — focus events are unreliable on Windows when the window
// is shown programmatically without foreground rights, so we keep the timer.
// Settle window for the picker. Showing a window programmatically produces a
// focus/blur burst of its own — `show()` is posted asynchronously on Windows
// while `focus()` is not — so a blur arriving inside this window is the
// activation settling, not the user clicking away.
//
// The timer is the *only* thing that arms the guard. It used to be armed on
// the first onWindowFocus as well, which meant the focus event generated by
// showing the window armed it immediately and the very next blur closed the
// picker before the user could click anything.
static const _pickerSettleDelay = Duration(milliseconds: 350);
bool _pickerBlurReady = false;
Timer? _blurGuardTimer;

Expand All @@ -33,6 +39,10 @@ final class _NavigateAppState extends ConsumerState<NavigateApp>
void initState() {
super.initState();
windowManager.addListener(this);
// A cold start that carries a URL builds this widget already in picker
// mode, so the listener below never fires for it. Without arming here the
// picker could never be dismissed by clicking away.
if (ref.read(appStateProvider).mode == AppMode.picker) _armBlurGuard();
}

@override
Expand All @@ -42,6 +52,14 @@ final class _NavigateAppState extends ConsumerState<NavigateApp>
super.dispose();
}

void _armBlurGuard() {
_blurGuardTimer?.cancel();
_pickerBlurReady = false;
_blurGuardTimer = Timer(_pickerSettleDelay, () {
_pickerBlurReady = true;
});
}

@override
void onWindowClose() async {
await windowManager.hide();
Expand All @@ -57,18 +75,11 @@ final class _NavigateAppState extends ConsumerState<NavigateApp>
ref.invalidate(isDefaultBrowserProvider);
ref.invalidate(isStartupEnabledProvider);
}

final mode = ref.read(appStateProvider).mode;
if (mode == AppMode.picker && !_pickerBlurReady) {
_blurGuardTimer?.cancel();
_pickerBlurReady = true;
}
}

@override
void onWindowBlur() {
final mode = ref.read(appStateProvider).mode;
if (mode != AppMode.picker) return;
if (ref.read(appStateProvider).mode != AppMode.picker) return;
if (!_pickerBlurReady) return;
ref.read(appStateProvider.notifier).hide();
}
Expand All @@ -79,24 +90,18 @@ final class _NavigateAppState extends ConsumerState<NavigateApp>
final locale = ref.watch(localeProvider);
final themeMode = ref.watch(themeModeProvider);

// Window geometry and visibility are driven exclusively from bootstrap's
// serialised transition queue. A second show()/focus() from here raced it
// one frame later and could leave the window on screen after the state had
// already collapsed back to hidden — a visible frame with no content.
ref.listen<AppState>(appStateProvider, (prev, next) {
if (prev?.mode == next.mode) return;
_blurGuardTimer?.cancel();
_pickerBlurReady = false;

if (next.mode == AppMode.picker) {
// Fallback: if focus events never fire (e.g. on Windows without
// foreground rights), mark ready after 350 ms so blur can still close.
_blurGuardTimer = Timer(const Duration(milliseconds: 350), () {
_pickerBlurReady = true;
});
_armBlurGuard();
} else {
_blurGuardTimer?.cancel();
_pickerBlurReady = false;
}

if (next.mode == AppMode.hidden) return;
WidgetsBinding.instance.addPostFrameCallback((_) async {
await windowManager.show();
await windowManager.focus();
});
});

return MaterialApp(
Expand All @@ -108,10 +113,44 @@ final class _NavigateAppState extends ConsumerState<NavigateApp>
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: switch (appState.mode) {
AppMode.hidden => const ColoredBox(color: Color(0xFF1E1E2E)),
AppMode.hidden => const _HiddenGuard(),
AppMode.settings => const SettingsWindow(),
AppMode.picker => PickerWindow(url: appState.pendingUrl ?? ''),
AppMode.picker => PickerWindow(
url: appState.pendingUrl ?? '',
origin: appState.pendingOrigin,
),
},
);
}
}

/// What the user sees if the window is ever visible while the app believes it
/// is hidden: an empty dark rectangle — the reported "just the frame".
///
/// Rather than only painting that rectangle, this reconciles the window with
/// the state, so any remaining race resolves itself on the next frame instead
/// of leaving an empty window on screen.
final class _HiddenGuard extends StatefulWidget {
const _HiddenGuard();

@override
State<_HiddenGuard> createState() => _HiddenGuardState();
}

final class _HiddenGuardState extends State<_HiddenGuard> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
try {
if (await windowManager.isVisible()) await windowManager.hide();
} on Object {
// Best-effort reconciliation; the window plugin may be unavailable.
}
});
}

@override
Widget build(BuildContext context) =>
const ColoredBox(color: Color(0xFF1E1E2E));
}
Loading
Loading