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
11 changes: 10 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,13 @@ jobs:

- name: Resolve version
id: v
# The input reaches the script as an env var, never spliced into it,
# so a crafted version string can't run as shell.
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ inputs.version }}"
VERSION="$INPUT_VERSION"
DRAFT=true
else
VERSION="${GITHUB_REF_NAME#v}"
Expand All @@ -71,6 +75,11 @@ jobs:

- run: flutter pub get

# A tag can point at any commit, including one CI never saw green.
# Nothing ships unless the same checks pass on exactly what is built.
- run: flutter analyze
- run: flutter test

- name: Build
run: |
flutter build macos --release \
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ jobs:

- run: flutter pub get

# This deploy runs beside CI, not after it, so it gates itself: a push
# that breaks analyze or the tests never reaches production.
- run: flutter analyze
- run: flutter test

# Each deploy keeps the pubspec version and bumps the build number, so
# open tabs see a strictly newer version.json and offer "Refresh"
# (lib/update/update_service.dart). The build lives under /b/<run>/ so
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@

## 2026-11-03

- File ▸ Show crash log opens the folder with dbscope.log for bug reports (desktop) (2026-11-03)
- dbscope_pg_proxy can be pinned to one database with --allow-db-host, and caps each result at --max-rows rows (default 100000, flagged truncated) (2026-11-03)
- Releases and web deploys run analyze and the tests on exactly the commit they ship (2026-11-03)
- The web app is served with clickjacking, MIME-sniffing and HSTS security headers (2026-11-03)
- macOS self-update checks that the download is signed by the same Developer ID team as the running app, and gives up on a stalled download (2026-11-03)
- Exported connection links need a passphrase of at least 10 characters (2026-11-03)
- New connections to a database on another machine default to SSL mode require (2026-11-03)
- dbscope_pg_proxy now accepts WebSocket upgrades only from the hosted web app and localhost pages by default (--allow-origin replaces the list, --allow-any-origin turns the check off), so other web pages can't use it to reach your databases (2026-11-03)
- Fixed: one malformed message could crash dbscope_pg_proxy for every client (2026-11-03)
- Fixed: read-only mode was not enforced on PG Proxy connections; the database now refuses writes as it does on direct connections (2026-11-03)
- Fixed: Quit could hang when a database connection had gone away (2026-11-03)
- Fixed: a saved tab or connection that could not be read could stop dbscope from starting or hide every saved connection (2026-11-03)
- Uncaught errors are now written to ~/Library/Logs/dbscope/dbscope.log, and a widget that fails to draw shows a short message instead of a grey box (2026-11-03)
- Cells in point, line, lseg, box, path, polygon and circle columns can be edited on Postgres; the save used to fail every time, and the editor now starts from Postgres syntax like (1,2) (2026-11-03)
- SQLite reports 0 rows affected for DDL instead of the count from the previous write (2026-11-03)
- DBSCOPE_AUTO_READ_ONLY (and DBSCOPE_AUTO_ASSIGN_IP) accept 1/0, yes/no, on/off in any case; =1 used to open the connection read-write (2026-11-03)
Expand Down
16 changes: 16 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,22 @@ rg -n '\[SIMPLE\]' TODO.md

<!-- New entries go here, newest first. -->

### **[P2]** Find out whether the release build still needs `disable-library-validation`

- **Where.** `macos/Runner/Release.entitlements` (`com.apple.security.cs.disable-library-validation`)
- **Why.** With hardened runtime it lets the app load libraries signed by anyone, which undoes much of what signing buys. The release workflow signs every bundled dylib with our own identity, so it may no longer be needed; removing it needs a signed build launched on a Mac (sqlite3 and the plugin frameworks are the libraries to watch).
- **Effort.** `<1h`
- **Blocker.** needs a signed build and a Mac (see "Sign and notarize the macOS release" in `MANUAL_TODO.md`).
- **Noticed.** 2026-11-03, while auditing for production readiness.

### **[P2]** Undo the Cloud SQL public IP after a crash

- **Where.** `lib/db/cloudsql/cloudsql_proxy_manager.dart`
- **Why.** When the manager assigned a public IP, it removes it on the last release. If dbscope crashes or is force-quit first, the instance keeps the public IP (and the orphaned `cloud-sql-proxy` keeps running) until someone notices. Recording "we assigned it" on disk and offering to undo it on the next launch would close that.
- **Effort.** `<4h`
- **Blocker.** none
- **Noticed.** 2026-11-03, while auditing for production readiness.

### **[P2]** Make Tab move to the next cell, and let multi-line cells take a newline, in grid inline editing

- **Where.** `packages/data_grid/lib/src/internal/grid_cell.dart` (`_editor` shortcut bindings)
Expand Down
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,5 +295,6 @@ The driver (`postgres ^3.5.11`) does not constrain the floor further in practice

## Other notes

- **Uncaught errors and quitting:** `installErrorHandlers` (`lib/util/error_reporting.dart`, first call in `main` for every isolate) sends framework errors and uncaught async errors to the console and, on desktop, to `~/Library/Logs/dbscope/dbscope.log` (rotated at 1 MB; `<temp>/dbscope/` off macOS; File ▸ Show crash log opens the folder). Uncaught async errors are marked handled so one failed background task doesn't kill the window; release builds show a small placeholder instead of a grey box for a widget that threw. Each quit step (save tabs, close connections, stop the backend) is capped at 3 s and logged on failure, so a dead database socket can't hang Quit. Anything restored before the first frame (saved tabs, saved profiles) skips an entry it can't read instead of throwing; `ProfileStore` writes unreadable profile entries back unchanged on save.
- **Table-open latency:** `TableLoader.loadTable` (`lib/query/runner.dart`) fires `listColumns`, `primaryKey`, `indexedColumns` together via `Future.wait` (the driver pipelines them); only the first-page `fetchNext` blocks on the catalog round. `TableTabView` also keeps a single-slot prefetch (`_nextPagePrefetch` / `_kickPrefetch`) so the next page is buffered before the user scrolls to it. The pager is not safe for concurrent `fetchNext` — the slot enforces one in-flight at a time, and `_load` / errors clear it.
- **Relationship gutter:** `lib/shell/relationship_gutter.dart` routes each FK edge in its own vertical lane keyed by sorted index (so an edge's lane and color are stable across reloads). Adjust `laneSpacing` / `laneMin` / `cornerRadius` together — the lane count is derived from the available width up to `anchorX - cornerRadius`.
2 changes: 1 addition & 1 deletion firebase.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"hosting":{"public":"build/web","ignore":["firebase.json","**/.*","**/node_modules/**"],"rewrites":[{"source":"**","destination":"/index.html"}],"headers":[{"source":"**","headers":[{"key":"Cache-Control","value":"no-cache"}]},{"source":"/b/**","headers":[{"key":"Cache-Control","value":"public, max-age=31536000, immutable"}]},{"source":"/index.html","headers":[{"key":"Cache-Control","value":"no-cache, no-store, must-revalidate"}]}]},"flutter":{"platforms":{"dart":{"lib/firebase_options.dart":{"projectId":"dbscope-prod","configurations":{"web":"1:593558037618:web:eb7caea728dd21ba29ceeb"}}}}}}
{"hosting":{"public":"build/web","ignore":["firebase.json","**/.*","**/node_modules/**"],"rewrites":[{"source":"**","destination":"/index.html"}],"headers":[{"source":"**","headers":[{"key":"X-Content-Type-Options","value":"nosniff"},{"key":"X-Frame-Options","value":"DENY"},{"key":"Content-Security-Policy","value":"frame-ancestors 'none'"},{"key":"Referrer-Policy","value":"strict-origin-when-cross-origin"},{"key":"Strict-Transport-Security","value":"max-age=31536000"},{"key":"Permissions-Policy","value":"camera=(), microphone=(), geolocation=(), payment=(), usb=()"}]},{"source":"**","headers":[{"key":"Cache-Control","value":"no-cache"}]},{"source":"/b/**","headers":[{"key":"Cache-Control","value":"public, max-age=31536000, immutable"}]},{"source":"/index.html","headers":[{"key":"Cache-Control","value":"no-cache, no-store, must-revalidate"}]}]},"flutter":{"platforms":{"dart":{"lib/firebase_options.dart":{"projectId":"dbscope-prod","configurations":{"web":"1:593558037618:web:eb7caea728dd21ba29ceeb"}}}}}}
25 changes: 20 additions & 5 deletions lib/connection/connection_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ class _ConnectionDialogState extends State<ConnectionDialog> {
/// touch it on a new profile, it follows [suggestReadOnly] as they type
/// the name and host, and says why.
var _readOnlyChosen = false;

/// Same as [_readOnlyChosen], for the SSL mode: until the user picks one
/// on a new profile, it follows [suggestSslMode] as they type the host.
var _sslChosen = false;
var _passwordVisible = false;
var _forgetPassword = false;

Expand All @@ -48,7 +52,8 @@ class _ConnectionDialogState extends State<ConnectionDialog> {
_tenantValue = TextEditingController(
text: i?.tenantFilter?.value?.toString() ?? '',
);
_sslMode = i?.sslMode ?? SslMode.disable;
_sslMode = i?.sslMode ?? suggestSslMode(_host.text);
_sslChosen = i != null;
_readOnlyChosen = i != null;
_readOnly = i?.readOnly ?? _suggestion.readOnly;
_name.addListener(_followSuggestion);
Expand All @@ -59,9 +64,13 @@ class _ConnectionDialogState extends State<ConnectionDialog> {
suggestReadOnly(name: _name.text, host: _host.text);

void _followSuggestion() {
if (_readOnlyChosen) return;
final suggested = _suggestion.readOnly;
if (suggested != _readOnly) setState(() => _readOnly = suggested);
final readOnly = _readOnlyChosen ? _readOnly : _suggestion.readOnly;
final ssl = _sslChosen ? _sslMode : suggestSslMode(_host.text);
if (readOnly == _readOnly && ssl == _sslMode) return;
setState(() {
_readOnly = readOnly;
_sslMode = ssl;
});
}

@override
Expand Down Expand Up @@ -199,6 +208,9 @@ class _ConnectionDialogState extends State<ConnectionDialog> {
),
const SizedBox(height: 12.0),
DropdownButtonFormField<SslMode>(
// Keyed on the value so a suggestion change re-seeds the field
// (`initialValue` is read only once per field state).
key: ValueKey(_sslMode),
initialValue: _sslMode,
decoration: const InputDecoration(labelText: 'SSL mode'),
items: const [
Expand All @@ -215,7 +227,10 @@ class _ConnectionDialogState extends State<ConnectionDialog> {
child: Text('verify-full'),
),
],
onChanged: (mode) => setState(() => _sslMode = mode ?? SslMode.disable),
onChanged: (mode) => setState(() {
_sslMode = mode ?? _sslMode;
_sslChosen = true;
}),
),
const SizedBox(height: 12.0),
SwitchListTile(
Expand Down
15 changes: 14 additions & 1 deletion lib/connection/connection_link_flow.dart
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,17 @@ class _PassphraseDialogState extends State<_PassphraseDialog> {
),
),
),
validator: (text) => (text == null || text.isEmpty) ? 'required' : null,
validator: (text) {
if (text == null || text.isEmpty) return 'required';
// Only when setting one: the file carries the database
// password, and anyone holding it can guess offline as long
// as they like. Importing must still accept whatever an
// older build exported.
if (widget.confirm && text.length < _kMinPassphraseLength) {
return 'use at least $_kMinPassphraseLength characters';
}
return null;
},
onFieldSubmitted: (_) {
if (!widget.confirm) _submit();
},
Expand All @@ -249,3 +259,6 @@ class _PassphraseDialogState extends State<_PassphraseDialog> {
);
}
}

/// Shortest passphrase an exported connection link accepts.
const _kMinPassphraseLength = 10;
16 changes: 14 additions & 2 deletions lib/connection/profile.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,25 @@ final readOnlyHeuristic = RegExp(
if (match != null) {
return (readOnly: true, reason: 'the name says "${match.group(2)!.toLowerCase()}"');
}
final h = host.trim().toLowerCase();
if (h.isEmpty || h == 'localhost' || h == '::1' || h.startsWith('127.')) {
if (isLocalHost(host)) {
return (readOnly: false, reason: 'the database is on this machine');
}
return (readOnly: true, reason: 'the database is on another server');
}

/// Whether [host] is this machine (blank counts: the driver's default is
/// localhost).
bool isLocalHost(String host) {
final h = host.trim().toLowerCase();
return h.isEmpty || h == 'localhost' || h == '::1' || h.startsWith('127.');
}

/// The SSL default the connection dialog proposes for a new profile:
/// `disable` for a database on this machine (a local Postgres usually has no
/// certificate), `require` for any other server, so the password and every
/// row never cross a network in the clear unless the user asks for that.
SslMode suggestSslMode(String host) => isLocalHost(host) ? SslMode.disable : SslMode.require;

/// Built-in kind id for the direct-Postgres backend. External plugins
/// pick their own.
const kPostgresKind = 'postgres';
Expand Down
28 changes: 24 additions & 4 deletions lib/connection/profile_store.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'package:flutter/foundation.dart';

import '../project/project_store.dart';
import 'profile.dart';
import 'secure_store.dart';
Expand All @@ -9,21 +11,39 @@ class ProfileStore {
ProfileStore._();
static final instance = ProfileStore._();

/// Saved entries this build can't parse (hand-edited, or written by a
/// newer build). They are left out of [load] and written back unchanged by
/// [save], so one bad entry neither hides every other profile nor gets
/// silently deleted by the next save.
final List<Map<String, Object?>> _unreadable = [];

Future<List<ConnectionProfile>> load() async {
final projectDocument = await ProjectStore.instance.load();
final out = <ConnectionProfile>[];
_unreadable.clear();
for (final entry in projectDocument.profiles) {
final id = entry['id'] as String?;
if (id == null) continue;
final password = await SecureStore.instance.readPassword(id) ?? '';
out.add(ConnectionProfile.fromJson(entry, password));
final id = entry['id'];
if (id is! String) {
_unreadable.add(entry);
continue;
}
try {
final password = await SecureStore.instance.readPassword(id) ?? '';
out.add(ConnectionProfile.fromJson(entry, password));
} catch (e) {
debugPrint('Skipped saved connection "$id": it could not be read ($e).');
_unreadable.add(entry);
}
}
return out;
}

Future<void> save(List<ConnectionProfile> profiles) async {
final ids = {for (final profile in profiles) profile.id};
await ProjectStore.instance.setProfiles([
for (final profile in profiles) profile.toJson(),
for (final entry in _unreadable)
if (!ids.contains(entry['id'])) entry,
]);
for (final profile in profiles) {
if (profile.password.isNotEmpty) {
Expand Down
29 changes: 25 additions & 4 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import 'server/host.dart';
import 'shell/app_state.dart';
import 'shell/ui_prefs.dart';
import 'update/update_service.dart';
import 'util/error_reporting.dart';
import 'util/shell_env.dart';

// Entrypoint for both the host (first) window and any subwindows spawned
Expand All @@ -46,6 +47,11 @@ import 'util/shell_env.dart';
// set (no Postgres-direct, no local SQLite).
Future<void> main(List<String> args) async {
WidgetsFlutterBinding.ensureInitialized();
installErrorHandlers(
isolateLabel: !kIsWeb && args.isNotEmpty && args.first == 'multi_window'
? 'window ${args.length > 1 ? args[1] : '?'}'
: 'host',
);
// Firebase is wired for the web build only. firebase_options.dart is
// configured for `web` (project dbscope-prod); desktop targets throw
// UnsupportedError from DefaultFirebaseOptions, so the init is gated on
Expand Down Expand Up @@ -181,13 +187,28 @@ class _HostQuitHook extends WidgetsBindingObserver {

@override
Future<ui.AppExitResponse> didRequestAppExit() async {
await AppState.instance.flushTabs();
await ConnectionRegistry.instance.disconnectAll();
await host.dispose();
await _quitStep('save open tabs', AppState.instance.flushTabs);
await _quitStep('close connections', ConnectionRegistry.instance.disconnectAll);
await _quitStep('stop the backend', host.dispose);
return ui.AppExitResponse.exit;
}
}

/// Longest one quit step may take. Closing a connection to a database that
/// went away can wait on a dead socket, and quitting must never hang on it.
const _kQuitStepTimeout = Duration(seconds: 3);

/// Runs one cleanup step on quit. A step that throws or hangs is logged and
/// skipped, so the app always quits.
Future<void> _quitStep(String name, Future<void> Function() step) async {
try {
await step().timeout(_kQuitStepTimeout);
} catch (e, st) {
debugPrint('Quit: could not $name: $e');
recordError(e, st, context: 'quit: $name');
}
}

// Universal plugin registration — one shape for every contribution.
//
// Every entry here implements [DbscopePlugin]. Its `register(host)`
Expand Down Expand Up @@ -227,7 +248,7 @@ void _registerPlugins() {
class _SubwindowQuitHook extends WidgetsBindingObserver {
@override
Future<ui.AppExitResponse> didRequestAppExit() async {
await ConnectionRegistry.instance.disconnectAll();
await _quitStep('close connections', ConnectionRegistry.instance.disconnectAll);
return ui.AppExitResponse.exit;
}
}
6 changes: 6 additions & 0 deletions lib/plugins/builtin_plugin.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import 'package:ai_broker/ai_broker.dart';
import 'package:flutter/foundation.dart' show kIsWeb;

import '../jobs/job_tab_kind.dart';
import '../json_viewer/json_tab_kind.dart' show jsonTabKind;
import '../linked/linked_tab_kind.dart';
import '../navigator/navigator_tab_kind.dart';
import '../query/query_tab_kind.dart';
import '../schema/schema_tab_kind.dart';
import '../shell/crash_log_menu.dart';
import '../shell/sidebar_table_actions_default.dart';
import '../table/table_tab_kind.dart';
import 'dbscope_plugin.dart';
Expand All @@ -23,6 +25,7 @@ import 'dbscope_plugin.dart';
/// another DB)
/// - the three packaged AI brokers (OpenAI, Anthropic, Gemini) from
/// `package:ai_broker`
/// - File ▸ Show crash log, on desktop
class BuiltinPlugin extends DbscopePlugin {
@override
String get id => 'builtin';
Expand All @@ -45,6 +48,9 @@ class BuiltinPlugin extends DbscopePlugin {
// Sidebar table-row actions.
registerDefaultSidebarTableActions();

// File ▸ Show crash log (desktop: the web build has no log file).
if (!kIsWeb) host.menus.register(crashLogMenuContribution);

// AI brokers.
host.aiBrokers
..register(OpenAiBroker())
Expand Down
Loading
Loading