diff --git a/.gitattributes b/.gitattributes index 1f98005..a6d8603 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,6 @@ app/dist/app.shellcanvas.json -text dist/adapter-windows-x86_64/bin/*.exe -text dist/adapter-windows-x86_64/** -text +dist/adapter-linux-x86_64/bin/* -text +dist/adapter-macos-x86_64/bin/* -text +dist/adapter-macos-aarch64/bin/* -text diff --git a/.github/workflows/build-linux-connector.yml b/.github/workflows/build-linux-connector.yml new file mode 100644 index 0000000..2ee15c6 --- /dev/null +++ b/.github/workflows/build-linux-connector.yml @@ -0,0 +1,34 @@ +name: Verify Linux Database connector + +# A focused Linux build allows retrying a runner-library failure without +# repeating the paid macOS matrix jobs. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + package: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Linux native libraries + run: | + sudo apt-get update + sudo apt-get install -y libwayland-dev libxkbcommon-dev libgtk-3-dev libsqlite3-dev libdbus-1-dev pkg-config + - name: Install Rust + run: | + rustup toolchain install stable --profile minimal + rustup default stable + rustc --version + - name: Build and validate native connector + run: | + cargo run --locked --manifest-path vendor/shellcanvas-adapter-sdk/Cargo.toml --bin shellcanvas-adapter -- build adapter dist/adapter-linux-x86_64 + cargo run --locked --manifest-path vendor/shellcanvas-adapter-sdk/Cargo.toml --bin shellcanvas-adapter -- validate dist/adapter-linux-x86_64/adapter.json + - uses: actions/upload-artifact@v4 + with: + name: database-adapter-linux-x86_64 + path: dist/adapter-linux-x86_64/ + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/build-macos-connector.yml b/.github/workflows/build-macos-connector.yml new file mode 100644 index 0000000..5e29c55 --- /dev/null +++ b/.github/workflows/build-macos-connector.yml @@ -0,0 +1,55 @@ +name: Verify macOS Database connectors + +# Focused retry after the first matrix used the updater's "darwin" name rather +# than the Rust/native-adapter platform name "macos". +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + package: + strategy: + fail-fast: false + matrix: + include: + - platform: macos-x86_64 + arch: x86_64 + runner: macos-15-intel + - platform: macos-aarch64 + arch: arm64 + runner: macos-15 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Install Rust + run: | + rustup toolchain install stable --profile minimal + rustup default stable + rustc --version + - name: Build and validate native connector + run: | + cargo run --locked --manifest-path vendor/shellcanvas-adapter-sdk/Cargo.toml --bin shellcanvas-adapter -- build adapter dist/adapter-${{ matrix.platform }} + cargo run --locked --manifest-path vendor/shellcanvas-adapter-sdk/Cargo.toml --bin shellcanvas-adapter -- validate dist/adapter-${{ matrix.platform }}/adapter.json + - name: Check binary architecture and package identity + env: + PLATFORM: ${{ matrix.platform }} + ARCH: ${{ matrix.arch }} + run: | + python3 - <<'PY' + import json, os, pathlib, subprocess + root = pathlib.Path('dist') / ('adapter-' + os.environ['PLATFORM']) + manifest = json.loads((root / 'adapter.json').read_text()) + assert manifest['platform'] == os.environ['PLATFORM'] + assert manifest['version'] == '0.1.1' + binary = root / manifest['entrypoint'] + assert binary.is_file() + assert subprocess.check_output(['lipo', '-archs', binary], text=True).strip() == os.environ['ARCH'] + PY + - uses: actions/upload-artifact@v4 + with: + name: database-adapter-${{ matrix.platform }} + path: dist/adapter-${{ matrix.platform }}/ + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/build-native-platforms.yml b/.github/workflows/build-native-platforms.yml new file mode 100644 index 0000000..469aa07 --- /dev/null +++ b/.github/workflows/build-native-platforms.yml @@ -0,0 +1,57 @@ +name: Build Database native connectors + +# CI keeps these build artifacts for review. Release only after all platform +# packages and their hashes have been checked together. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + package: + strategy: + fail-fast: false + matrix: + include: + - platform: linux-x86_64 + runner: ubuntu-22.04 + - platform: macos-x86_64 + runner: macos-15-intel + - platform: macos-aarch64 + runner: macos-15 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Linux native libraries + if: matrix.platform == 'linux-x86_64' + run: | + sudo apt-get update + sudo apt-get install -y libwayland-dev libxkbcommon-dev libgtk-3-dev libsqlite3-dev libdbus-1-dev pkg-config + - name: Install Rust + run: | + rustup toolchain install stable --profile minimal + rustup default stable + rustc --version + - name: Build and validate native connector + run: | + cargo run --locked --manifest-path vendor/shellcanvas-adapter-sdk/Cargo.toml --bin shellcanvas-adapter -- build adapter dist/adapter-${{ matrix.platform }} + cargo run --locked --manifest-path vendor/shellcanvas-adapter-sdk/Cargo.toml --bin shellcanvas-adapter -- validate dist/adapter-${{ matrix.platform }}/adapter.json + - name: Check platform identity + env: + PLATFORM: ${{ matrix.platform }} + run: | + python3 - <<'PY' + import json, os, pathlib + root = pathlib.Path('dist') / ('adapter-' + os.environ['PLATFORM']) + manifest = json.loads((root / 'adapter.json').read_text()) + assert manifest['platform'] == os.environ['PLATFORM'] + assert manifest['version'] == '0.1.1' + assert (root / manifest['entrypoint']).is_file() + PY + - uses: actions/upload-artifact@v4 + with: + name: database-adapter-${{ matrix.platform }} + path: dist/adapter-${{ matrix.platform }}/ + if-no-files-found: error + retention-days: 7 diff --git a/Cargo.lock b/Cargo.lock index fcb407c..13fc07d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1539,7 +1539,7 @@ dependencies = [ [[package]] name = "shellcanvas-database-adapter" -version = "0.1.0" +version = "0.1.1" dependencies = [ "base64 0.22.1", "bigdecimal", diff --git a/Cargo.toml b/Cargo.toml index 026143d..d2bf95a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["adapter"] resolver = "2" [workspace.package] -version = "0.1.0" +version = "0.1.1" edition = "2024" license = "MPL-2.0" diff --git a/README.md b/README.md index a1d630c..fd8ff18 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,15 @@ A first-party companion app for [ShellCanvas](https://github.com/techartdev/ShellCanvas) that connects directly to PostgreSQL, MySQL/MariaDB, Microsoft SQL Server, and SQLite. -Version 0.1.0 browses schemas and tables, runs SQL, displays bounded typed results, pages through the retained result, and saves the full retained result as CSV. It does not provide grid editing or a DDL designer. +Version 0.1.1 browses schemas and tables, runs SQL, displays bounded typed results, pages through the retained result, and saves the full retained result as CSV. It does not provide grid editing or a DDL designer. > **SQL is not filtered into a read-only subset.** Statements run with the configured database account's privileges. Use database grants appropriate for your work. The app repeats this warning at run time. -## Install the initial Windows build +## Install -With ShellCanvas 0.1.12 or later, open **App Manager → Add apps → From GitHub** and enter `techartdev/ShellCanvas-Database` with reference `main` (or `v0.1.0` for this release). Review and install the app and its Windows connector together. +Open **App Manager → Add apps → From GitHub** and enter `techartdev/ShellCanvas-Database` with reference `main` (or `v0.1.1` for this release). Review and install the app and its connector together. The repository provides native connectors for Windows x86_64, Linux x86_64, macOS Intel, and macOS Apple Silicon. ShellCanvas selects the package matching the client machine. + +Windows requires ShellCanvas 0.1.12 or later. Linux and macOS require a ShellCanvas release with native installed-app frames enabled; the 0.1.12 desktop build still blocks those frames. Open Database, choose **Connect database**, then select SQL Server, PostgreSQL, MySQL, MariaDB or SQLite. Enter the host, TCP port, database and credentials, or an existing SQLite file path. The app shows the endpoint and actual connection state. No database connection starts until you press Connect. Passwords are not saved. @@ -16,14 +18,14 @@ Each app window owns its connection; Disconnect or closing the window releases i There is no automatic network or SSH-host instance discovery. Network connections default to the connected SSH host: 127.0.0.1 means that remote host. The SSH server must allow TCP forwarding. Choose **This PC (direct connection)** explicitly for local access. SQL Server named instances require their TCP port. SQLite remains a local-file option; remote SQLite execution is not implemented. Schema/table enumeration starts after a connection succeeds. -ShellCanvas 0.1.12 is required for the combined installation and SSH-host routing. +ShellCanvas 0.1.12 introduced combined installation and SSH-host routing. The Linux and macOS app-frame gate is addressed separately in the desktop repository. ShellCanvas 0.1.11 and older require the existing manual order: install `dist/adapter-windows-x86_64/adapter.json` under **Connection adapters**, add and configure that source, then install `app/dist/app.shellcanvas.json` or the app from GitHub and grant `services.dev.shellcanvas.database`. -This app is intentionally not in ShellCanvas's recommended catalog yet. The checked-in Windows package is the initial test build. PostgreSQL, MySQL/MariaDB, and SQL Server paths compile but still need disposable-server and user acceptance testing; SQLite has an automated real-database integration test. +This app is intentionally not in ShellCanvas's recommended catalog yet. The native packages have automated build and integrity checks; Linux and macOS still need manual app testing. PostgreSQL, MySQL/MariaDB, and SQL Server paths compile but still need disposable-server and user acceptance testing; SQLite has an automated real-database integration test. ## Connection fields @@ -34,7 +36,7 @@ Set `provider` to `sqlite`, `postgresql`, `mysql`, `mariadb`, or `sqlserver`. - TLS verifies the certificate and host identity by default. Trusting a server certificate without identity verification is an explicit insecure opt-in for private/self-signed deployments. - Password fields are not retained in saved ShellCanvas profiles; supply the password when connecting. -The app requires ShellCanvas 0.1.12 or later and the `host.tcp` permission to use SSH routing. The desktop pins the accepted host connection, opens an SSH direct-tcpip channel, and supplies a loopback transport to this app’s private connector. UI and database drivers run on the desktop; SQL runs on the remote database. Closing/replacing the host retires its tunnels without reconnecting or falling back to the PC. Database TLS hostname verification remains unchanged. See `vendor/sqlx-core/SHELLCANVAS.md` for the transport-only SQLx patch. +The app requires `host.tcp` permission to use SSH routing. The desktop pins the accepted host connection, opens an SSH direct-tcpip channel, and supplies a loopback transport to this app’s private connector. UI and database drivers run on the desktop; SQL runs on the remote database. Closing/replacing the host retires its tunnels without reconnecting or falling back to the PC. Database TLS hostname verification remains unchanged. See `vendor/sqlx-core/SHELLCANVAS.md` for the transport-only SQLx patch. ## Results and CSV @@ -61,10 +63,10 @@ The repository command uses the pinned 0.1.11 app SDK for the app artifact, then validates and reattaches the native dependency with a freshly computed adapter manifest hash. Regeneration therefore cannot silently drop or stale the pin. -Build/package the native adapter with the vendored ShellCanvas 0.1.11 SDK: +Build/package the native adapter on each target OS and architecture with the vendored ShellCanvas 0.1.11 SDK: ```powershell -cargo run --manifest-path vendor/shellcanvas-adapter-sdk/Cargo.toml --bin shellcanvas-adapter -- build adapter dist/adapter-windows-x86_64 +cargo run --locked --manifest-path vendor/shellcanvas-adapter-sdk/Cargo.toml --bin shellcanvas-adapter -- build adapter dist/adapter- ``` Use a fresh output directory for each package. The source pins SQLx 0.8.6 and uses concrete PostgreSQL, MySQL, and SQLite decoders rather than SQLx `Any`. SQL Server uses Tiberius 0.12.3. Optional live-provider credentials are never stored here; future disposable-server tests will be explicitly gated. Never point development tests at production databases. diff --git a/adapter/adapter.json b/adapter/adapter.json index f3abdee..f290ee5 100644 --- a/adapter/adapter.json +++ b/adapter/adapter.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "dev.shellcanvas.database", "name": "ShellCanvas Database", - "version": "0.1.0", + "version": "0.1.1", "description": "Database connections through the connected SSH host, or directly from this PC.", "platform": "current", "entrypoint": "bin/shellcanvas-device{exe}", diff --git a/app/dist/app.shellcanvas.json b/app/dist/app.shellcanvas.json index 5673891..c63dbb4 100644 --- a/app/dist/app.shellcanvas.json +++ b/app/dist/app.shellcanvas.json @@ -1 +1 @@ -{"format":1,"kind":"app","id":"dev.shellcanvas.database","version":"0.1.0","title":"ShellCanvas Database","description":"Browse database schemas, run SQL, inspect typed results and export CSV.","permissions":["services.dev.shellcanvas.database","host.tcp"],"script":"\"use strict\";\n(() => {\n // app/node_modules/@techartdev/shellcanvas-app-sdk/dist/rpc.js\n var RpcError = class extends Error {\n code;\n constructor(code, message2) {\n super(message2);\n this.code = code;\n this.name = \"RpcError\";\n }\n };\n var codes = [\n \"invalid\",\n \"closed\",\n \"aborted\",\n \"denied\",\n \"unavailable\",\n \"busy\",\n \"failed\"\n ];\n var maxMessage = 4 * 1024 * 1024;\n var maxPending = 64;\n var methodName = /^[a-z][a-z0-9-]*(?:\\.[a-zA-Z][a-zA-Z0-9-]*)+$/;\n function isJsonValue(value) {\n const pending = [{ value, depth: 0 }];\n while (pending.length) {\n const item = pending.pop();\n if (item.depth > 64)\n return false;\n if (item.value === null || typeof item.value === \"string\" || typeof item.value === \"boolean\")\n continue;\n if (typeof item.value === \"number\" && Number.isFinite(item.value))\n continue;\n if (typeof item.value !== \"object\" || item.value === null)\n return false;\n if (!Array.isArray(item.value) && Object.getPrototypeOf(item.value) !== Object.prototype)\n return false;\n for (const child of Object.values(item.value))\n pending.push({ value: child, depth: item.depth + 1 });\n }\n return true;\n }\n function encode(value) {\n if (!isJsonValue(value))\n throw new RpcError(\"invalid\", \"Only finite JSON values with at most 64 nesting levels are supported.\");\n const text = JSON.stringify(value);\n if (text.length > maxMessage)\n throw new RpcError(\"invalid\", \"RPC message exceeds 4 Mi UTF-16 units; stream bulk data separately.\");\n return text;\n }\n function decode(raw) {\n if (typeof raw !== \"string\" || raw.length > maxMessage)\n throw new RpcError(\"invalid\", \"Invalid RPC message.\");\n const value = JSON.parse(raw);\n if (!isJsonValue(value) || !value || typeof value !== \"object\" || Array.isArray(value) || value.v !== 1)\n throw new RpcError(\"invalid\", \"Unsupported RPC envelope.\");\n if (value.type === \"close\")\n return { v: 1, type: \"close\" };\n const id = value.id;\n if (typeof id !== \"number\" || !Number.isSafeInteger(id) || id < 1)\n throw new RpcError(\"invalid\", \"Invalid request identity.\");\n if (value.type === \"request\" && typeof value.method === \"string\" && methodName.test(value.method) && Object.hasOwn(value, \"params\"))\n return {\n v: 1,\n type: \"request\",\n id,\n method: value.method,\n params: value.params\n };\n if (value.type === \"cancel\")\n return { v: 1, type: \"cancel\", id };\n if (value.type === \"result\" && Object.hasOwn(value, \"value\"))\n return { v: 1, type: \"result\", id, value: value.value };\n if (value.type === \"error\" && typeof value.code === \"string\" && codes.includes(value.code) && typeof value.message === \"string\")\n return {\n v: 1,\n type: \"error\",\n id,\n code: value.code,\n message: value.message\n };\n throw new RpcError(\"invalid\", \"Invalid RPC envelope.\");\n }\n var RpcPeer = class {\n transport;\n closed = false;\n serial = 0;\n received = 0;\n unsubscribe = () => {\n };\n pending = /* @__PURE__ */ new Map();\n running = /* @__PURE__ */ new Map();\n methods;\n grants;\n closeListeners = /* @__PURE__ */ new Set();\n constructor(transport, methods = /* @__PURE__ */ new Map(), grants = []) {\n this.transport = transport;\n this.methods = new Map([...methods].map(([name, method]) => {\n if (!methodName.test(name))\n throw new RpcError(\"invalid\", `Invalid method name: ${name}`);\n return [\n name,\n {\n grants: [...method.grants],\n invoke: method.invoke,\n available: method.available\n }\n ];\n }));\n this.grants = new Set(grants);\n this.unsubscribe = transport.subscribe((raw) => this.receive(raw), () => this.finish(new RpcError(\"closed\", \"Extension channel disconnected.\")));\n }\n get isClosed() {\n return this.closed;\n }\n onClose(listener) {\n if (this.closed) {\n listener();\n return () => {\n };\n }\n this.closeListeners.add(listener);\n return () => {\n this.closeListeners.delete(listener);\n };\n }\n call(method, params = null, signal) {\n if (this.closed)\n return Promise.reject(new RpcError(\"closed\", \"Extension instance has closed.\"));\n if (signal?.aborted)\n return Promise.reject(new RpcError(\"aborted\", \"Request canceled.\"));\n if (!methodName.test(method))\n return Promise.reject(new RpcError(\"invalid\", \"Invalid method name.\"));\n if (this.pending.size >= maxPending)\n return Promise.reject(new RpcError(\"busy\", \"Too many pending requests.\"));\n const id = ++this.serial;\n if (!Number.isSafeInteger(id)) {\n this.close();\n return Promise.reject(new RpcError(\"closed\", \"Request identities exhausted.\"));\n }\n return new Promise((resolve, reject) => {\n const abort = () => {\n const request = this.pending.get(id);\n if (!request)\n return;\n this.pending.delete(id);\n request.cleanup();\n reject(new RpcError(\"aborted\", \"Request canceled; an already dispatched write may have completed.\"));\n this.send({ v: 1, type: \"cancel\", id });\n };\n this.pending.set(id, {\n resolve,\n reject,\n cleanup: () => signal?.removeEventListener(\"abort\", abort)\n });\n signal?.addEventListener(\"abort\", abort, { once: true });\n try {\n this.transport.send(encode({ v: 1, type: \"request\", id, method, params }));\n } catch (error) {\n this.pending.get(id)?.cleanup();\n this.pending.delete(id);\n reject(error instanceof RpcError ? error : new RpcError(\"closed\", \"Unable to send request.\"));\n if (!(error instanceof RpcError))\n this.close();\n }\n });\n }\n /** Revocation retires the whole instance, so no outstanding operation keeps an old grant. */\n close() {\n if (this.closed)\n return;\n this.send({ v: 1, type: \"close\" });\n this.finish(new RpcError(\"closed\", \"Extension instance has closed.\"));\n }\n finish(reason) {\n if (this.closed)\n return;\n this.closed = true;\n this.unsubscribe();\n for (const request of this.pending.values()) {\n request.cleanup();\n request.reject(reason);\n }\n this.pending.clear();\n for (const controller of this.running.values())\n controller.abort();\n this.running.clear();\n for (const listener of this.closeListeners) {\n try {\n listener();\n } catch {\n }\n }\n this.closeListeners.clear();\n this.transport.close();\n }\n send(value) {\n if (this.closed)\n return;\n try {\n this.transport.send(encode(value));\n } catch {\n this.finish(new RpcError(\"closed\", \"Extension channel failed.\"));\n }\n }\n receive(raw) {\n if (this.closed)\n return;\n let value;\n try {\n value = decode(raw);\n } catch {\n this.close();\n return;\n }\n if (value.type === \"close\") {\n this.finish(new RpcError(\"closed\", \"Extension instance has closed.\"));\n return;\n }\n if (value.type === \"cancel\") {\n this.running.get(value.id)?.abort();\n return;\n }\n if (value.type === \"result\" || value.type === \"error\") {\n const request = this.pending.get(value.id);\n if (!request)\n return;\n this.pending.delete(value.id);\n request.cleanup();\n if (value.type === \"result\")\n request.resolve(value.value);\n else\n request.reject(new RpcError(value.code, value.message));\n return;\n }\n if (value.id <= this.received) {\n this.close();\n return;\n }\n this.received = value.id;\n void this.dispatch(value);\n }\n async dispatch(request) {\n const fail = (code, message2) => this.send({ v: 1, type: \"error\", id: request.id, code, message: message2 });\n const method = this.methods.get(request.method);\n if (!method) {\n fail(\"unavailable\", \"Unknown service method.\");\n return;\n }\n if (method.grants.some((grant) => !this.grants.has(grant))) {\n fail(\"denied\", \"The extension does not hold the required permission.\");\n return;\n }\n if (this.running.size >= maxPending) {\n fail(\"busy\", \"Too many active requests.\");\n return;\n }\n const controller = new AbortController();\n this.running.set(request.id, controller);\n try {\n if (method.available && !method.available())\n throw new RpcError(\"unavailable\", \"This service is currently unavailable.\");\n const result = await method.invoke(request.params, controller.signal);\n if (!this.closed && !controller.signal.aborted) {\n encode({ v: 1, type: \"result\", id: request.id, value: result });\n this.send({ v: 1, type: \"result\", id: request.id, value: result });\n }\n } catch (error) {\n if (!controller.signal.aborted && !this.closed)\n fail(error instanceof RpcError ? error.code : \"failed\", error instanceof RpcError ? error.message : \"Service operation failed.\");\n } finally {\n this.running.delete(request.id);\n }\n }\n };\n function messagePortTransport(port) {\n return {\n send: (message2) => port.postMessage(message2),\n subscribe(receive, disconnected) {\n const message2 = (event) => receive(event.data);\n port.addEventListener(\"message\", message2);\n port.addEventListener(\"messageerror\", disconnected);\n port.start();\n return () => {\n port.removeEventListener(\"message\", message2);\n port.removeEventListener(\"messageerror\", disconnected);\n };\n },\n close: () => port.close()\n };\n }\n\n // app/node_modules/@techartdev/shellcanvas-app-sdk/dist/network-client.js\n function appNetworkClient(peer) {\n return {\n profile: async (slot, signal) => await peer.call(\"system.network.profile\", { slot }, signal),\n configure: async (options, signal) => await peer.call(\"system.network.configure\", {\n slot: options.slot,\n suggestedEndpoint: options.suggestedEndpoint ?? \"\"\n }, signal),\n forget: async (slot, signal) => {\n await peer.call(\"system.network.forget\", { slot }, signal);\n },\n async postJSON(options, signal) {\n const id = crypto.randomUUID();\n let closed2 = false, reading = false;\n let closing;\n const close = () => {\n closed2 = true;\n signal?.removeEventListener(\"abort\", abort);\n return closing ??= peer.call(\"system.network.close\", { id }).then(() => {\n });\n };\n const abort = () => {\n void close().catch(() => {\n });\n };\n signal?.throwIfAborted();\n const starting = peer.call(\"system.network.start\", { id, ...options }, signal);\n signal?.addEventListener(\"abort\", abort, { once: true });\n try {\n const head = await starting;\n if (closed2 || signal?.aborted)\n throw new RpcError(\"aborted\", \"HTTP request canceled.\");\n return {\n ...head,\n close,\n async read(control) {\n if (closed2)\n throw new RpcError(\"closed\", \"HTTP response closed.\");\n if (reading)\n throw new RpcError(\"busy\", \"Wait for the current response read.\");\n reading = true;\n control?.addEventListener(\"abort\", abort, { once: true });\n try {\n control?.throwIfAborted();\n const bytes = await peer.call(\"system.network.read\", { id }, control);\n if (bytes === null) {\n await close();\n return null;\n }\n return Uint8Array.from(bytes);\n } catch (error) {\n abort();\n throw error;\n } finally {\n reading = false;\n control?.removeEventListener(\"abort\", abort);\n }\n }\n };\n } catch (error) {\n abort();\n throw error;\n }\n }\n };\n }\n\n // app/node_modules/@techartdev/shellcanvas-app-sdk/dist/file-client.js\n function appFileClient(peer) {\n return Object.freeze({\n makeDirectory: async ({ binding, parent, name }, signal) => await peer.call(\"system.files.makeDirectory\", { binding, parent, name }, signal),\n renameEntry: async ({ binding, path, revision }, name, signal) => await peer.call(\"system.files.renameEntry\", { binding, path, revision, name }, signal),\n moveEntry: async ({ binding, path, revision }, parent, signal) => await peer.call(\"system.files.moveEntry\", {\n binding,\n path,\n revision,\n parent: parent.path,\n parentBinding: parent.binding\n }, signal),\n removeEntry: async ({ binding, path, revision }, signal) => {\n await peer.call(\"system.files.removeEntry\", { binding, path, revision }, signal);\n },\n async *list({ binding, path }, signal) {\n const id = crypto.randomUUID();\n let closing;\n const close = () => closing ??= peer.call(\"system.files.listClose\", { id }).catch(() => {\n });\n const cancel = () => {\n void close();\n };\n signal?.addEventListener(\"abort\", cancel, { once: true });\n try {\n let result = await peer.call(\"system.files.listStart\", { id, binding, path: path ?? null }, signal);\n while (true) {\n yield result.directory;\n if (result.done)\n break;\n result = await peer.call(\"system.files.listNext\", { id }, signal);\n }\n } finally {\n signal?.removeEventListener(\"abort\", cancel);\n await close();\n }\n },\n readText: async ({ binding, path }, signal) => await peer.call(\"system.files.readText\", { binding, path }, signal),\n saveText: async ({ binding, path, revision }, text, signal) => await peer.call(\"system.files.saveText\", { binding, path, revision, text }, signal),\n createText: async ({ binding, parent, name, text }, signal) => await peer.call(\"system.files.createText\", { binding, parent, name, text }, signal)\n });\n }\n\n // app/node_modules/@techartdev/shellcanvas-app-sdk/dist/console-client.js\n function appConsoleClient(peer) {\n return {\n async open(options, signal) {\n const id = crypto.randomUUID();\n let closed2 = false;\n let closing;\n let reading = false;\n let writing = false;\n let stopPeer = () => {\n };\n const close = () => {\n if (closing)\n return closing;\n closed2 = true;\n stopPeer();\n signal?.removeEventListener(\"abort\", abort);\n closing = peer.call(\"system.console.close\", { id }).then(() => {\n });\n return closing;\n };\n const abort = () => {\n void close().catch(() => {\n });\n };\n stopPeer = peer.onClose(() => {\n closed2 = true;\n signal?.removeEventListener(\"abort\", abort);\n closing ??= Promise.resolve();\n });\n if (signal?.aborted) {\n stopPeer();\n throw new RpcError(\"aborted\", \"Console opening canceled.\");\n }\n const opening = peer.call(\"system.console.open\", {\n id,\n binding: options.binding,\n cols: options.cols ?? 80,\n rows: options.rows ?? 24\n }, signal);\n signal?.addEventListener(\"abort\", abort, { once: true });\n try {\n const result = await opening;\n if (closed2 || signal?.aborted)\n throw new RpcError(\"aborted\", \"Console opening canceled.\");\n const call2 = async (method, params, control) => {\n if (closed2)\n throw new RpcError(\"closed\", \"Console is closed.\");\n const onAbort = () => {\n void close().catch(() => {\n });\n };\n control?.addEventListener(\"abort\", onAbort, { once: true });\n try {\n if (control?.aborted) {\n onAbort();\n throw new RpcError(\"aborted\", \"Console operation canceled; the console was closed.\");\n }\n return await peer.call(method, params, control);\n } finally {\n control?.removeEventListener(\"abort\", onAbort);\n }\n };\n return {\n binding: result.binding,\n resizable: result.resizable,\n close,\n async read(control) {\n if (reading)\n throw new RpcError(\"busy\", \"Wait for the current console read.\");\n reading = true;\n try {\n const value = await call2(\"system.console.read\", { id }, control);\n return value === null ? null : Uint8Array.from(value.bytes);\n } finally {\n reading = false;\n }\n },\n async write(data, control) {\n if (writing)\n throw new RpcError(\"busy\", \"Wait for the current console write.\");\n const bytes = typeof data === \"string\" ? new TextEncoder().encode(data) : new Uint8Array(data);\n writing = true;\n try {\n for (let offset = 0; offset < bytes.length || offset === 0; offset += 65536)\n await call2(\"system.console.write\", {\n id,\n bytes: Array.from(bytes.subarray(offset, offset + 65536))\n }, control);\n } finally {\n writing = false;\n }\n },\n async resize(cols, rows, control) {\n if (!result.resizable)\n throw new RpcError(\"unavailable\", \"This console has fixed dimensions.\");\n await call2(\"system.console.resize\", { id, cols, rows }, control);\n }\n };\n } catch (error) {\n void close().catch(() => {\n });\n throw error;\n }\n }\n };\n }\n\n // app/node_modules/@techartdev/shellcanvas-app-sdk/dist/transfer-client.js\n function appTransferClient(peer) {\n const handle = (item) => {\n let closed2 = false;\n let running;\n let closing;\n const call2 = (method, extra = {}, signal) => {\n if (closed2)\n return Promise.reject(new RpcError(\"closed\", \"Transfer handle is closed.\"));\n return peer.call(`system.transfers.${method}`, { id: item.id, ...extra }, signal);\n };\n const cancel = async () => {\n await call2(\"cancel\");\n };\n return {\n binding: item.binding,\n name: item.name,\n size: item.size,\n direction: item.direction,\n async run(signal) {\n const abort = () => {\n void cancel().catch(() => {\n });\n };\n signal?.addEventListener(\"abort\", abort, { once: true });\n try {\n if (signal?.aborted)\n await cancel();\n running ??= call2(\"run\");\n return await running;\n } finally {\n signal?.removeEventListener(\"abort\", abort);\n }\n },\n async status() {\n return await call2(\"status\", {\n after: null\n });\n },\n async *watch(signal) {\n let after = null;\n for (; ; ) {\n const snapshot = await call2(\"status\", { after }, signal);\n yield snapshot;\n if (snapshot.result)\n return;\n after = snapshot.revision;\n }\n },\n cancel,\n close() {\n if (closing)\n return closing;\n closing = call2(\"close\").then(() => {\n closed2 = true;\n }, (error) => {\n closing = void 0;\n throw error;\n });\n return closing;\n }\n };\n };\n async function prepare(method, options, signal) {\n const id = crypto.randomUUID();\n try {\n const items = await peer.call(`system.transfers.${method}`, { id, ...options }, signal);\n if (signal?.aborted)\n throw new RpcError(\"aborted\", \"Transfer preparation canceled.\");\n return items.map(handle);\n } catch (error) {\n void peer.call(\"system.transfers.cancelPreparation\", { id }).catch(() => {\n });\n throw error;\n }\n }\n return {\n pasteClipboard: async (destination2, signal) => {\n const snapshot = await peer.call(\"system.transfers.clipboardInspect\", { binding: destination2.binding }, signal);\n if (snapshot.kind === \"empty\")\n return [];\n return prepare(snapshot.sequence === null ? \"clipboardPaste\" : snapshot.kind === \"remote\" ? snapshot.intent === \"move\" ? \"clipboardMoveSnapshot\" : \"clipboardCopySnapshot\" : \"clipboardPasteSnapshot\", {\n binding: destination2.binding,\n parent: destination2.path,\n ...snapshot.sequence === null ? {} : { sequence: snapshot.sequence }\n }, signal);\n },\n upload: (destination2, options, signal) => prepare(options?.folder ? \"uploadFolder\" : \"upload\", { binding: destination2.binding, parent: destination2.path }, signal),\n async download(entry, signal) {\n return (await prepare(\"download\", {\n binding: entry.binding,\n path: entry.path,\n revision: entry.revision\n }, signal))[0] ?? null;\n },\n downloadMany: (entries, signal) => prepare(\"downloadMany\", {\n binding: entries[0]?.binding ?? \"\",\n entries: entries.map((entry) => ({\n binding: entry.binding,\n path: entry.path,\n revision: entry.revision\n }))\n }, signal),\n async copy(entry, destination2, signal) {\n return (await prepare(\"copy\", {\n binding: entry.binding,\n path: entry.path,\n revision: entry.revision,\n parent: destination2.path,\n parentBinding: destination2.binding\n }, signal))[0];\n }\n };\n }\n\n // app/node_modules/@techartdev/shellcanvas-app-sdk/dist/host-settings-client.js\n function appHostSettingsClient(peer) {\n return {\n read: async (location, signal) => await peer.call(\"system.hostSettings.read\", { binding: location.binding }, signal),\n apply: async (setting, value, signal) => await peer.call(\"system.hostSettings.apply\", {\n binding: setting.binding,\n id: setting.id,\n revision: setting.revision,\n value\n }, signal)\n };\n }\n\n // app/node_modules/@techartdev/shellcanvas-app-sdk/dist/events-client.js\n function appEventClient(peer) {\n const listeners = /* @__PURE__ */ new Set();\n const latest = /* @__PURE__ */ new Map();\n let cursor = null, controller;\n const error = (listener, failure) => {\n try {\n listener.error?.(failure);\n } catch {\n }\n };\n const deliver = (listener, batch) => {\n if (listeners.has(listener))\n try {\n void Promise.resolve(listener.receive(structuredClone(batch))).catch((failure) => error(listener, failure));\n } catch (failure) {\n error(listener, failure);\n }\n };\n const poll = async (active) => {\n try {\n while (listeners.size && controller === active) {\n const batch = await peer.call(\"system.events.next\", { after: cursor }, active.signal);\n if (controller !== active || active.signal.aborted)\n return;\n cursor = batch.cursor;\n if (batch.reset)\n latest.clear();\n for (const event of batch.events)\n latest.set(event.topic, event);\n for (const listener of [...listeners])\n deliver(listener, batch);\n }\n } catch (failure) {\n if (controller !== active || active.signal.aborted)\n return;\n const stopped = [...listeners];\n listeners.clear();\n controller = void 0;\n latest.clear();\n cursor = null;\n for (const listener of stopped)\n error(listener, failure);\n }\n };\n return Object.freeze({\n subscribe(receive, onError) {\n const listener = { receive, error: onError };\n listeners.add(listener);\n if (!controller) {\n controller = new AbortController();\n void poll(controller);\n } else if (cursor !== null) {\n const snapshot = {\n cursor,\n reset: true,\n events: [...latest.values()].sort((a, b) => a.sequence - b.sequence)\n };\n queueMicrotask(() => deliver(listener, snapshot));\n }\n return () => {\n listeners.delete(listener);\n if (!listeners.size) {\n const previous = controller;\n controller = void 0;\n previous?.abort();\n cursor = null;\n latest.clear();\n }\n };\n }\n });\n }\n\n // app/node_modules/@techartdev/shellcanvas-app-sdk/dist/clipboard-image.js\n var imageChunkSize = 32 * 1024;\n function imageByteLength(width, height) {\n if (typeof width !== \"number\" || typeof height !== \"number\" || !Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0 || width > 4294967295 || height > 4294967295 || !Number.isSafeInteger(width * height * 4))\n throw new RpcError(\"invalid\", \"Provide positive image dimensions with a representable RGBA length.\");\n return width * height * 4;\n }\n function imageBytes(value) {\n return Array.isArray(value) && value.length > 0 && value.length <= imageChunkSize && value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255);\n }\n function appImageClipboardClient(peer) {\n const release = (id) => peer.call(\"system.clipboard.image.release\", { id }).catch(() => {\n });\n return {\n async readImage(signal) {\n const id = crypto.randomUUID();\n try {\n const meta = await peer.call(\"system.clipboard.image.readStart\", { id }, signal);\n const length = imageByteLength(meta?.width, meta?.height);\n const rgba = new Uint8Array(length);\n let offset = 0;\n while (offset < length) {\n const bytes = await peer.call(\"system.clipboard.image.readChunk\", { id, offset }, signal);\n if (!imageBytes(bytes) || offset + bytes.length > length)\n throw new RpcError(\"failed\", \"Invalid clipboard image stream.\");\n rgba.set(bytes, offset);\n offset += bytes.length;\n }\n return { width: meta.width, height: meta.height, rgba };\n } finally {\n await release(id);\n }\n },\n async writeImage(image, signal) {\n const length = imageByteLength(image?.width, image?.height);\n if (!(image.rgba instanceof Uint8Array) || image.rgba.length !== length)\n throw new RpcError(\"invalid\", \"Image dimensions must match the RGBA bytes.\");\n const rgba = image.rgba.slice(), width = image.width, height = image.height;\n const id = crypto.randomUUID();\n try {\n await peer.call(\"system.clipboard.image.writeStart\", { id, width, height }, signal);\n for (let offset = 0; offset < length; offset += imageChunkSize)\n await peer.call(\"system.clipboard.image.writeChunk\", {\n id,\n offset,\n bytes: Array.from(rgba.subarray(offset, offset + imageChunkSize))\n }, signal);\n await peer.call(\"system.clipboard.image.writeCommit\", { id }, signal);\n } finally {\n await release(id);\n }\n }\n };\n }\n\n // app/node_modules/@techartdev/shellcanvas-app-sdk/dist/clipboard-client.js\n var chunkSize = 64 * 1024;\n function appClipboardClient(peer) {\n const release = (id) => peer.call(\"system.clipboard.release\", { id }).catch(() => {\n });\n return Object.freeze({\n async cutFile(entry, signal) {\n if (!entry || typeof entry.binding !== \"string\" || !entry.binding || typeof entry.path !== \"string\" || !entry.path || typeof entry.revision !== \"string\" || !entry.revision)\n throw new RpcError(\"invalid\", \"Cut a current file entry from an accepted workspace binding.\");\n const { binding, path, revision } = entry;\n const id = crypto.randomUUID();\n try {\n await peer.call(\"system.clipboard.files.cut\", { id, binding, path, revision }, signal);\n } finally {\n await peer.call(\"system.clipboard.files.release\", { id }).catch(() => {\n });\n }\n },\n async copyFiles(entries, signal) {\n const snapshot = entries.map(({ binding, path, revision }) => ({\n binding,\n path,\n revision\n }));\n if (!snapshot.length || snapshot.some((entry) => entry.binding !== snapshot[0].binding || !entry.path || !entry.revision))\n throw new RpcError(\"invalid\", \"Copy current file entries from one workspace binding.\");\n const id = crypto.randomUUID();\n try {\n await peer.call(\"system.clipboard.files.start\", { id, binding: snapshot[0].binding }, signal);\n for (let offset = 0; offset < snapshot.length; ) {\n const chunk = [];\n let size = 0;\n while (offset + chunk.length < snapshot.length && chunk.length < 128) {\n const { path, revision } = snapshot[offset + chunk.length];\n const entry = { path, revision };\n const length = JSON.stringify(entry).length;\n if (chunk.length && size + length > 64 * 1024)\n break;\n chunk.push(entry);\n size += length;\n }\n await peer.call(\"system.clipboard.files.append\", {\n id,\n offset,\n entries: chunk\n }, signal);\n offset += chunk.length;\n }\n await peer.call(\"system.clipboard.files.commit\", { id }, signal);\n } finally {\n await peer.call(\"system.clipboard.files.release\", { id }).catch(() => {\n });\n }\n },\n pasteFiles: appTransferClient(peer).pasteClipboard,\n ...appImageClipboardClient(peer),\n async readText(signal) {\n const id = crypto.randomUUID();\n const chunks = [];\n try {\n const start2 = await peer.call(\"system.clipboard.readStart\", { id }, signal);\n let offset = 0;\n while (offset < start2.length) {\n const chunk = await peer.call(\"system.clipboard.readChunk\", { id: start2.id, offset }, signal);\n if (typeof chunk !== \"string\" || !chunk.length || offset + chunk.length > start2.length)\n throw new RpcError(\"failed\", \"Invalid clipboard text stream.\");\n chunks.push(chunk);\n offset += chunk.length;\n }\n return chunks.join(\"\");\n } finally {\n await release(id);\n }\n },\n async writeText(text, signal) {\n if (typeof text !== \"string\")\n throw new RpcError(\"invalid\", \"Clipboard text must be a string.\");\n const id = crypto.randomUUID();\n try {\n await peer.call(\"system.clipboard.writeStart\", { id, length: text.length }, signal);\n for (let offset = 0; offset < text.length; offset += chunkSize)\n await peer.call(\"system.clipboard.writeChunk\", { id, offset, text: text.slice(offset, offset + chunkSize) }, signal);\n await peer.call(\"system.clipboard.writeCommit\", { id }, signal);\n } finally {\n await release(id);\n }\n }\n });\n }\n\n // app/node_modules/@techartdev/shellcanvas-app-sdk/dist/client.js\n function connectToShellCanvas(timeoutMs = 1e4) {\n return new Promise((resolve, reject) => {\n const token = document.querySelector('meta[name=\"shellcanvas-instance\"]')?.content;\n if (!token) {\n reject(new Error(\"Missing ShellCanvas instance identity.\"));\n return;\n }\n if (window.parent === window) {\n reject(new Error(\"Launch this app inside ShellCanvas.\"));\n return;\n }\n const timer = setTimeout(() => {\n window.removeEventListener(\"message\", receive);\n reject(new Error(\"ShellCanvas did not establish an app channel.\"));\n }, timeoutMs);\n const receive = (event) => {\n if (event.source !== window.parent || event.data?.type !== \"shellcanvas:connect:v1\" || event.data?.token !== token || event.ports.length !== 1)\n return;\n clearTimeout(timer);\n window.removeEventListener(\"message\", receive);\n const peer = new RpcPeer(messagePortTransport(event.ports[0]));\n let lastFocus = document.activeElement;\n const rememberFocus = (event2) => {\n if (event2.target instanceof HTMLElement && event2.target !== document.body)\n lastFocus = event2.target;\n };\n document.addEventListener(\"focusin\", rememberFocus);\n const dispose = () => {\n document.removeEventListener(\"focusin\", rememberFocus);\n window.removeEventListener(\"pagehide\", dispose);\n peer.close();\n };\n const call2 = async (method, params, signal) => {\n const origin = document.activeElement === document.body ? lastFocus : document.activeElement;\n try {\n return await peer.call(method, JSON.parse(JSON.stringify(params)), signal);\n } finally {\n requestAnimationFrame(() => requestAnimationFrame(() => {\n if (!peer.isClosed && origin instanceof HTMLElement && origin.isConnected)\n origin.focus();\n }));\n }\n };\n const system = Object.freeze({\n apiVersion: 1,\n dialogs: Object.freeze({\n messageBox: async (options, control) => await call2(\"system.dialogs.messageBox\", options, control?.signal),\n openFile: async (options = {}, control) => await call2(\"system.dialogs.openFile\", options, control?.signal),\n saveFile: async (options = {}, control) => await call2(\"system.dialogs.saveFile\", options, control?.signal)\n }),\n files: Object.freeze({\n saveTextAs: async (options, control) => await call2(\"system.files.saveTextAs\", options, control?.signal)\n })\n });\n window.addEventListener(\"pagehide\", dispose, { once: true });\n const storage = (namespace) => Object.freeze({\n get: async (key, signal) => await peer.call(`system.${namespace}.get`, { key }, signal),\n put: async (key, value, expectedRevision, signal) => await peer.call(`system.${namespace}.put`, { key, value, expectedRevision }, signal),\n remove: async (key, expectedRevision, signal) => {\n await peer.call(`system.${namespace}.remove`, { key, expectedRevision }, signal);\n },\n list: async (options = {}, signal) => await peer.call(`system.${namespace}.list`, JSON.parse(JSON.stringify(options)), signal)\n });\n resolve({\n network: appNetworkClient(peer),\n system,\n files: appFileClient(peer),\n console: appConsoleClient(peer),\n transfers: appTransferClient(peer),\n hostSettings: appHostSettingsClient(peer),\n storage: storage(\"storage\"),\n settings: storage(\"settings\"),\n environment: Object.freeze({\n get: async (signal) => await peer.call(\"system.environment.get\", null, signal)\n }),\n services: Object.freeze({\n call: (method, params = null, signal) => peer.call(\"system.services.call\", { method, params }, signal),\n list: async (signal) => await peer.call(\"system.services.list\", null, signal)\n }),\n events: appEventClient(peer),\n clipboard: appClipboardClient(peer),\n window: Object.freeze({\n getState: async (signal) => await peer.call(\"system.window.getState\", null, signal),\n focus: async (signal) => {\n await peer.call(\"system.window.focus\", null, signal);\n requestAnimationFrame(() => requestAnimationFrame(() => {\n if (!peer.isClosed && lastFocus instanceof HTMLElement && lastFocus.isConnected)\n lastFocus.focus({ preventScroll: true });\n }));\n },\n minimize: async (signal) => {\n await peer.call(\"system.window.minimize\", null, signal);\n },\n maximize: async (signal) => {\n await peer.call(\"system.window.maximize\", null, signal);\n },\n restore: async (signal) => {\n await peer.call(\"system.window.restore\", null, signal);\n },\n requestClose: async (signal) => {\n await peer.call(\"system.window.requestClose\", null, signal);\n },\n setDocumentState: async (state) => {\n await peer.call(\"system.window.setDocumentState\", JSON.parse(JSON.stringify(state)));\n }\n }),\n call: (method, params, signal) => peer.call(method, params, signal),\n dispose\n });\n };\n window.addEventListener(\"message\", receive);\n window.parent.postMessage({ type: \"shellcanvas:ready:v1\", token }, \"*\");\n });\n }\n\n // app/paging.js\n function nextOffset(page) {\n return page.offset + page.rows.length;\n }\n\n // app/connection-state.js\n var prefix = \"dev.shellcanvas.database.\";\n var required = [\n \"capabilities\",\n \"schemas\",\n \"tables\",\n \"query\",\n \"page\",\n \"closeResult\",\n \"exportBegin\",\n \"exportStatus\",\n \"exportCancel\"\n ];\n function connectionState(environment, methods, ownConnection = false) {\n const database = methods.filter((method) => method.name.startsWith(prefix));\n if (database.some((method) => !method.granted))\n return {\n ready: false,\n title: \"Database permission needed\",\n detail: \"Review this app in App Manager and allow its Database service permission.\"\n };\n if (!ownConnection && environment.connection === \"review-required\")\n return {\n ready: false,\n title: \"Review the reconnected host\",\n detail: \"Use the reconnected host in the bar above this app, or connect a database directly.\"\n };\n if (!ownConnection && environment.connection === \"disconnected\")\n return {\n ready: false,\n title: \"Workspace disconnected\",\n detail: \"Reconnect the workspace, or create a database connection directly from this app.\"\n };\n if (!database.length)\n return {\n ready: false,\n title: \"Connect a database\",\n detail: \"Choose a database server or an existing SQLite file to browse tables and run queries.\"\n };\n if (!required.every(\n (name) => database.some(\n (method) => method.name === prefix + name && method.available && method.granted\n )\n ))\n return {\n ready: false,\n title: \"Database service unavailable\",\n detail: \"The selected workspace does not expose all database operations. Reconnect it, or connect a database directly.\"\n };\n return {\n ready: true,\n title: \"Connected\",\n detail: \"Database services are available.\"\n };\n }\n var colors = [\n \"base\",\n \"surface\",\n \"raised\",\n \"inset\",\n \"text\",\n \"muted\",\n \"accent\",\n \"onAccent\",\n \"border\",\n \"hover\",\n \"selection\",\n \"danger\",\n \"dangerSoft\",\n \"warning\",\n \"warningSoft\",\n \"success\"\n ];\n function applyAppearance(root2, appearance) {\n if (!appearance) return;\n root2.dataset.mode = appearance.mode === \"light\" ? \"light\" : \"dark\";\n root2.style.setProperty(\"color-scheme\", root2.dataset.mode);\n for (const key of colors) {\n const value = appearance.colors[key];\n if (typeof value === \"string\" && /^#[0-9a-f]{6}$/i.test(value))\n root2.style.setProperty(`--sc-${key}`, value);\n }\n }\n\n // app/main.ts\n var service = \"dev.shellcanvas.database\";\n var databaseIcon = '';\n var root = document.querySelector(\"#root\");\n root.innerHTML = `\n
\n
${databaseIcon}

Database

No database connected

Not connected
\n
${databaseIcon}

Connect a database

Choose a database server or an existing SQLite file to browse tables and run queries.

SQL ServerPostgreSQLMySQL / MariaDBSQLite

Database connections use your connected SSH host. No public database port is needed.

\n \n
Checking the app connection\\u2026Database workspace
\n
\n
${databaseIcon}

Connect a database

Connect to a database on your SSH host or its network. Enter its address and credentials.

For SQL Server named instances, enter the database server and its TCP port.

Passwords are not saved.
`;\n var $ = (id) => document.getElementById(id);\n var input = (id) => $(id);\n var button = (id) => $(id);\n var sql = $(\"sql\");\n var dialog = $(\"connect-dialog\");\n var status = $(\"status\");\n var client;\n var current = null;\n var queryController = null;\n var connectController = null;\n var provider = \"\";\n var generation = 0;\n var schemaOp = 0;\n var pageOp = 0;\n var bindOp = 0;\n var exportJob = null;\n var pageBusy = false;\n var ready = false;\n var exportBusy = false;\n var previousOffsets = [];\n var signature = null;\n var privateConnection = false;\n var companionAvailable = false;\n var destination = \"\";\n var sshAvailable = false;\n var sshHostName = \"\";\n var closed = false;\n var pageSize = 100;\n function message(error) {\n return error instanceof RpcError ? error.message : error instanceof Error ? error.message : String(error);\n }\n function report(error) {\n status.textContent = message(error);\n }\n function setControls() {\n button(\"run\").disabled = !ready || !!queryController || !!connectController || exportBusy;\n button(\"cancel\").disabled = !queryController;\n button(\"reload\").disabled = !ready || !!connectController;\n button(\"export\").disabled = !ready || !current?.columns.length || exportBusy || !!queryController;\n button(\"prev\").disabled = !ready || pageBusy || !previousOffsets.length;\n button(\"next\").disabled = !ready || pageBusy || !current?.hasMore;\n button(\"disconnect\").hidden = !privateConnection || !ready;\n button(\"disconnect\").disabled = !!connectController;\n button(\"connection-settings\").disabled = !companionAvailable || !!connectController || !!queryController || exportBusy;\n button(\"setup-connect\").disabled = !companionAvailable || !!connectController;\n }\n async function call(method, params = {}, signal) {\n return privateConnection ? await client.call(\n \"system.companion.call\",\n { method: `${service}.${method}`, params },\n signal\n ) : await client.services.call(\n `${service}.${method}`,\n params,\n signal\n );\n }\n function unavailable(title, detail) {\n ready = false;\n $(\"connection-state\").textContent = \"Not connected\";\n $(\"connection-state\").classList.remove(\"connected\");\n $(\"destination\").textContent = \"No database connected\";\n $(\"setup\").hidden = false;\n $(\"workspace\").hidden = true;\n $(\"setup-title\").textContent = title;\n $(\"setup-detail\").textContent = detail;\n button(\"connection-settings\").textContent = \"Connect database\";\n status.textContent = detail;\n setControls();\n }\n function clearResult(note = \"Run a query to see results.\", release = true) {\n const old = current;\n current = null;\n if (release && old)\n void call(\"closeResult\", {\n resultId: old.resultId,\n offset: 0,\n pageSize: 1\n }).catch(() => {\n });\n document.querySelector(\"thead\").replaceChildren();\n document.querySelector(\"tbody\").replaceChildren();\n $(\"summary\").textContent = \"Results\";\n $(\"page\").textContent = \"\";\n $(\"warnings\").hidden = true;\n $(\"empty\").hidden = false;\n $(\"empty\").textContent = note;\n previousOffsets = [];\n setControls();\n }\n function render(page) {\n current = page;\n const head = document.querySelector(\"thead\"), body = document.querySelector(\"tbody\");\n head.replaceChildren();\n body.replaceChildren();\n if (page.columns.length) {\n const tr = document.createElement(\"tr\");\n for (const column of page.columns) {\n const th = document.createElement(\"th\");\n th.textContent = column.name;\n th.title = column.databaseType;\n tr.append(th);\n }\n head.append(tr);\n }\n for (const row of page.rows) {\n const tr = document.createElement(\"tr\");\n for (const cell of row) {\n const td = document.createElement(\"td\");\n td.textContent = cell.kind === \"null\" ? \"NULL\" : cell.display;\n td.className = `cell-${cell.kind}`;\n tr.append(td);\n }\n body.append(tr);\n }\n $(\"empty\").hidden = page.rows.length > 0;\n $(\"empty\").textContent = page.columns.length ? \"The query returned no rows.\" : `Statement completed. ${page.affectedRows.toLocaleString()} rows affected.`;\n $(\"summary\").textContent = page.columns.length ? `${page.totalRows.toLocaleString()} rows${page.truncated ? \" \\xB7 limit reached\" : \"\"}` : `${page.affectedRows.toLocaleString()} rows affected`;\n $(\"page\").textContent = page.totalRows ? `${page.offset + 1}\\u2013${page.offset + page.rows.length} of ${page.totalRows.toLocaleString()}` : \"\";\n $(\"warnings\").textContent = page.warnings.join(\" \\xB7 \");\n $(\"warnings\").hidden = !page.warnings.length;\n setControls();\n }\n function quote(name) {\n return provider === \"mysql\" ? `\\`${name.replaceAll(\"`\", \"``\")}\\`` : provider === \"sqlserver\" ? `[${name.replaceAll(\"]\", \"]]\")}]` : `\"${name.replaceAll('\"', '\"\"')}\"`;\n }\n function tableSql(schema, table) {\n const full = provider === \"sqlite\" ? quote(table) : `${quote(schema)}.${quote(table)}`;\n return provider === \"sqlserver\" ? `SELECT TOP (200) * FROM ${full};` : `SELECT * FROM ${full} LIMIT 200;`;\n }\n async function loadSchemas() {\n const op = ++schemaOp, gen = generation, tree = $(\"tree\");\n tree.textContent = \"Loading schemas\\u2026\";\n try {\n const value = await call(\"schemas\");\n if (op !== schemaOp || gen !== generation) return;\n tree.replaceChildren();\n for (const schema of value.schemas) {\n const details = document.createElement(\"details\"), summary = document.createElement(\"summary\");\n summary.textContent = schema;\n details.append(summary);\n details.addEventListener(\"toggle\", () => {\n if (details.open && details.childElementCount === 1)\n void loadTables(details, schema, gen);\n });\n tree.append(details);\n if (value.schemas.length === 1) details.open = true;\n }\n if (!value.schemas.length)\n tree.textContent = \"No schemas visible to this account.\";\n } catch (error) {\n if (op === schemaOp && gen === generation) {\n tree.textContent = \"Could not load schemas. Use Refresh to retry.\";\n report(error);\n }\n }\n }\n async function loadTables(details, schema, gen) {\n const wait = document.createElement(\"p\");\n wait.textContent = \"Loading tables\\u2026\";\n details.append(wait);\n try {\n const value = await call(\"tables\", { schema });\n if (gen !== generation || !details.isConnected) return;\n wait.remove();\n for (const table of value.tables) {\n const item = document.createElement(\"button\");\n item.className = \"table\";\n item.textContent = table.name;\n item.title = `${table.kind} \\xB7 insert a query`;\n item.onclick = () => {\n sql.value = tableSql(schema, table.name);\n sql.focus();\n };\n details.append(item);\n }\n if (!value.tables.length) {\n wait.textContent = \"No tables\";\n details.append(wait);\n }\n } catch (error) {\n if (gen === generation && details.isConnected) {\n wait.textContent = \"Could not load tables. Refresh schemas to retry.\";\n report(error);\n }\n }\n }\n async function execute() {\n if (!ready || queryController || !sql.value.trim() || connectController || exportBusy)\n return;\n const gen = generation, active = new AbortController();\n queryController = active;\n clearResult(\"Running query\\u2026\");\n setControls();\n status.textContent = \"Running query\\u2026\";\n try {\n const page = await call(\n \"query\",\n { sql: sql.value, pageSize },\n active.signal\n );\n if (gen !== generation) return;\n render(page);\n status.textContent = \"Query completed.\";\n } catch (error) {\n if (gen === generation) {\n clearResult(\n active.signal.aborted ? \"Query canceled. Check the database before retrying statements that change data.\" : \"The query failed.\"\n );\n report(error);\n }\n } finally {\n if (queryController === active) queryController = null;\n setControls();\n }\n }\n async function move(offset, previous = false) {\n if (!current || pageBusy || !ready) return;\n const op = ++pageOp, gen = generation, original = current;\n pageBusy = true;\n setControls();\n try {\n const page = await call(\"page\", {\n resultId: original.resultId,\n offset,\n pageSize\n });\n if (op === pageOp && gen === generation && current === original) {\n if (previous) previousOffsets.pop();\n else previousOffsets.push(original.offset);\n render(page);\n }\n } catch (error) {\n if (op === pageOp && gen === generation) report(error);\n } finally {\n if (op === pageOp) pageBusy = false;\n setControls();\n }\n }\n async function cancelExport() {\n const job = exportJob;\n exportJob = null;\n if (job) await call(\"exportCancel\", { jobId: job }).catch(() => {\n });\n }\n async function exportCsv() {\n if (!current || exportBusy) return;\n const gen = generation;\n exportBusy = true;\n setControls();\n status.textContent = \"Choose a CSV destination\\u2026\";\n try {\n const start2 = await call(\"exportBegin\", {\n resultId: current.resultId,\n suggestedName: \"query-results.csv\",\n spreadsheetSafe: input(\"safe\").checked\n });\n if (gen !== generation) {\n await call(\"exportCancel\", { jobId: start2.jobId }).catch(() => {\n });\n return;\n }\n exportJob = start2.jobId;\n setControls();\n for (let polls = 0; polls < 180 && exportJob === start2.jobId; polls++) {\n await new Promise((resolve) => setTimeout(resolve, 350));\n if (gen !== generation) return;\n const job = await call(\"exportStatus\", { jobId: start2.jobId });\n if (gen !== generation) return;\n if (job.state === \"saved\" || job.state === \"canceled\") {\n status.textContent = job.state === \"saved\" ? `Saved ${job.name ?? \"CSV\"}.` : \"CSV save canceled.\";\n exportJob = null;\n return;\n }\n if (job.state === \"failed\")\n throw new Error(job.message ?? \"CSV export failed\");\n if (job.state !== \"choosing\" && job.state !== \"writing\")\n throw new Error(\"Unknown CSV export state\");\n }\n throw new Error(\"CSV save timed out\");\n } catch (error) {\n if (gen === generation) {\n await cancelExport();\n report(error);\n }\n } finally {\n exportBusy = false;\n setControls();\n }\n }\n async function refresh(force = false) {\n if (closed || !client) return;\n const op = ++bindOp;\n try {\n const env = await client.environment.get();\n if (op !== bindOp || closed) return;\n applyAppearance(document.documentElement, env.appearance);\n const systemMethods = await client.services.list();\n if (op !== bindOp || closed) return;\n companionAvailable = systemMethods.some(\n (method) => method.name === \"system.companion.connect\" && method.available && method.granted\n );\n const own = companionAvailable ? await client.call(\"system.companion.status\") : { connected: false, binding: null };\n if (op !== bindOp || closed || connectController) return;\n const methods = own.connected ? await client.call(\n \"system.companion.list\"\n ) : systemMethods;\n if (op !== bindOp || closed) return;\n const routes = companionAvailable ? await client.call(\"system.companion.routes\").catch(() => ({ ssh: false })) : { ssh: false };\n if (op !== bindOp || closed) return;\n sshAvailable = routes.ssh;\n sshHostName = env.host?.name ?? \"connected host\";\n updateRoute();\n const next = JSON.stringify([\n own.connected,\n own.binding,\n own.connected ? null : env.binding,\n own.connected ? null : env.connection,\n methods.filter((m) => m.name.startsWith(service + \".\")).map((m) => [m.name, m.available, m.granted, m.source])\n ]);\n if (!force && signature === next) return;\n generation++;\n schemaOp++;\n pageOp++;\n pageBusy = false;\n ready = false;\n queryController?.abort();\n queryController = null;\n await cancelExport();\n if (op !== bindOp || closed) return;\n privateConnection = own.connected;\n clearResult(\"Run a query to see results.\", false);\n const state = connectionState(env, methods, own.connected);\n if (!state.ready) {\n signature = next;\n unavailable(state.title, state.detail);\n return;\n }\n $(\"connection-state\").textContent = \"Checking database\\u2026\";\n const info = await call(\"capabilities\");\n if (op !== bindOp || closed) return;\n signature = next;\n provider = info.provider;\n ready = true;\n $(\"setup\").hidden = true;\n $(\"workspace\").hidden = false;\n $(\"connection-state\").textContent = \"Connected\";\n $(\"connection-state\").classList.add(\"connected\");\n $(\"destination\").textContent = privateConnection ? destination || `${provider} \\xB7 app connection` : `${provider} \\xB7 ${env.host?.name ?? \"workspace\"}`;\n button(\"connection-settings\").textContent = \"Connection\\u2026\";\n status.textContent = \"Ready. Choose a table or write a query.\";\n setControls();\n await loadSchemas();\n } catch (error) {\n if (op === bindOp && !closed) {\n signature = null;\n unavailable(\"Connection unavailable\", message(error));\n }\n }\n }\n function updateRoute() {\n const remote = input(\"db-route\").value === \"ssh\";\n $(\"route-hint\").textContent = remote ? sshAvailable ? \"Through \" + sshHostName + \". 127.0.0.1 means that remote host.\" : \"Connect an SSH host first. If it was reconnected, accept the new connection.\" : \"Direct from this PC. 127.0.0.1 means this PC.\";\n }\n function selectProvider() {\n const sqlite = input(\"db-provider\").value === \"sqlite\";\n $(\"network-fields\").hidden = sqlite;\n $(\"sqlite-fields\").hidden = !sqlite;\n for (const id of [\n \"db-route\",\n \"db-host\",\n \"db-port\",\n \"db-name\",\n \"db-user\",\n \"db-password\",\n \"db-tls\",\n \"db-trust\"\n ])\n input(id).disabled = sqlite;\n input(\"db-path\").disabled = !sqlite;\n input(\"db-trust\").disabled = sqlite || !input(\"db-tls\").checked;\n for (const id of [\"db-host\", \"db-port\", \"db-name\", \"db-user\"])\n input(id).required = !sqlite;\n input(\"db-path\").required = sqlite;\n input(\"db-port\").value = String(\n {\n sqlserver: 1433,\n postgresql: 5432,\n mysql: 3306,\n mariadb: 3306\n }[input(\"db-provider\").value] ?? 0\n );\n }\n function openConnection() {\n if (!companionAvailable) return;\n $(\"connect-error\").hidden = true;\n if (!input(\"db-host\").value) input(\"db-host\").value = \"127.0.0.1\";\n updateRoute();\n dialog.showModal();\n $(\"db-provider\").focus();\n }\n function closeConnection() {\n connectController?.abort();\n input(\"db-password\").value = \"\";\n dialog.close();\n }\n async function connect() {\n if (connectController || !$(\"connect-form\").reportValidity())\n return;\n const route = input(\"db-provider\").value === \"sqlite\" ? \"direct\" : input(\"db-route\").value;\n if (route === \"ssh\" && !sshAvailable) {\n $(\"connect-error\").textContent = \"Connect an SSH host first and allow host TCP access when reviewing this app.\";\n $(\"connect-error\").hidden = false;\n return;\n }\n const active = new AbortController();\n connectController = active;\n setControls();\n $(\"connection-fields\").disabled = true;\n button(\"connect-submit\").disabled = true;\n button(\"connect-submit\").textContent = \"Connecting\\u2026\";\n $(\"connect-error\").hidden = true;\n const selected = input(\"db-provider\").value;\n const configuration = selected === \"sqlite\" ? { provider: selected, sqlitePath: input(\"db-path\").value.trim() } : {\n provider: selected,\n host: input(\"db-host\").value.trim(),\n port: Number(input(\"db-port\").value),\n database: input(\"db-name\").value.trim(),\n username: input(\"db-user\").value.trim(),\n password: input(\"db-password\").value,\n tls: input(\"db-tls\").checked,\n trustServerCertificate: input(\"db-tls\").checked && input(\"db-trust\").checked\n };\n const target = selected === \"sqlite\" ? input(\"db-path\").value.trim() : `${configuration.host}:${configuration.port} / ${configuration.database}`;\n status.textContent = `Connecting to ${target}${route === \"ssh\" ? ` through ${sshHostName}` : \" from this PC\"}\\u2026`;\n try {\n await client.call(\n \"system.companion.connect\",\n { configuration, route },\n active.signal\n );\n destination = `${$(\"db-provider\").selectedOptions[0].text} \\xB7 ${target} \\xB7 ${route === \"ssh\" ? sshHostName : \"this PC\"}`;\n input(\"db-password\").value = \"\";\n dialog.close();\n } catch (error) {\n if (!active.signal.aborted) {\n $(\"connect-error\").textContent = message(error);\n $(\"connect-error\").hidden = false;\n }\n status.textContent = active.signal.aborted ? \"Connection canceled.\" : `Could not connect to ${target}.`;\n } finally {\n connectController = null;\n $(\"connection-fields\").disabled = false;\n button(\"connect-submit\").disabled = false;\n button(\"connect-submit\").textContent = \"Connect\";\n await refresh(true);\n }\n }\n async function start() {\n client = await connectToShellCanvas();\n client.events.subscribe((batch) => {\n for (const event of batch.events)\n if (event.topic === \"system.environment\")\n applyAppearance(\n document.documentElement,\n event.value?.appearance\n );\n if (batch.events.some(\n (event) => [\"system.environment\", \"system.services\", \"system.companion\"].includes(\n event.topic\n )\n ))\n void refresh();\n }, report);\n window.addEventListener(\n \"pagehide\",\n () => {\n closed = true;\n generation++;\n bindOp++;\n queryController?.abort();\n connectController?.abort();\n client.dispose();\n },\n { once: true }\n );\n await refresh();\n }\n button(\"run\").onclick = () => void execute();\n button(\"cancel\").onclick = () => queryController?.abort();\n button(\"reload\").onclick = () => void loadSchemas();\n button(\"export\").onclick = () => void exportCsv();\n button(\"prev\").onclick = () => {\n const offset = previousOffsets.at(-1);\n if (offset !== void 0) void move(offset, true);\n };\n button(\"next\").onclick = () => {\n if (current) void move(nextOffset(current));\n };\n button(\"retry\").onclick = () => void refresh(true);\n button(\"connection-settings\").onclick = openConnection;\n button(\"setup-connect\").onclick = openConnection;\n button(\"close-dialog\").onclick = closeConnection;\n button(\"connect-cancel\").onclick = closeConnection;\n dialog.addEventListener(\"cancel\", (event) => {\n event.preventDefault();\n closeConnection();\n });\n button(\"connect-submit\").onclick = () => void connect();\n $(\"connect-form\").addEventListener(\"submit\", (event) => event.preventDefault());\n $(\"connect-form\").addEventListener(\"keydown\", (event) => {\n if (event.key === \"Enter\" && !event.isComposing && !event.ctrlKey && !event.metaKey && !event.altKey && event.target instanceof HTMLInputElement && event.target.type !== \"checkbox\") {\n event.preventDefault();\n void connect();\n }\n });\n $(\"db-provider\").addEventListener(\"change\", selectProvider);\n $(\"db-route\").addEventListener(\"change\", updateRoute);\n input(\"db-tls\").addEventListener(\"change\", () => {\n input(\"db-trust\").disabled = !input(\"db-tls\").checked;\n });\n button(\"disconnect\").onclick = () => {\n void (async () => {\n queryController?.abort();\n await cancelExport();\n await client.call(\"system.companion.disconnect\");\n destination = \"\";\n await refresh(true);\n })().catch(report);\n };\n sql.addEventListener(\"keydown\", (event) => {\n if ((event.ctrlKey || event.metaKey) && event.key === \"Enter\") {\n event.preventDefault();\n void execute();\n }\n });\n selectProvider();\n setControls();\n void start().catch(\n (error) => unavailable(\"ShellCanvas connection unavailable\", message(error))\n );\n})();\n","style":"/* SPDX-License-Identifier: MPL-2.0 */\n:root {\n --sc-base: #19191c;\n --sc-surface: #232326;\n --sc-raised: #303034;\n --sc-inset: #1d1d20;\n --sc-text: #e8e8ec;\n --sc-muted: #aaaab4;\n --sc-accent: #7ab8ff;\n --sc-onAccent: #14283e;\n --sc-border: #44444b;\n --sc-hover: #38383e;\n --sc-selection: #293f58;\n --sc-danger: #f2a4a4;\n --sc-dangerSoft: #482d32;\n --sc-warning: #eec778;\n --sc-success: #88cda5;\n font-family: \"Segoe UI\", system-ui, sans-serif;\n font-size: 13px;\n color-scheme: dark;\n}\n* {\n box-sizing: border-box;\n}\n[hidden] {\n display: none !important;\n}\nhtml,\nbody {\n margin: 0;\n height: 100%;\n background: var(--sc-surface);\n color: var(--sc-text);\n font:\n 13px/1.5 \"Segoe UI\",\n system-ui,\n sans-serif;\n}\nbutton,\ninput,\nselect,\ntextarea {\n font: inherit;\n}\nbutton {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 7px;\n min-height: 32px;\n padding: 6px 12px;\n border: 1px solid var(--sc-border);\n border-radius: 6px;\n background: transparent;\n color: var(--sc-text);\n cursor: pointer;\n white-space: nowrap;\n}\nbutton:hover:not(:disabled) {\n background: var(--sc-hover);\n}\nbutton:disabled {\n opacity: 0.45;\n cursor: default;\n}\nbutton.primary {\n background: var(--sc-accent);\n border-color: var(--sc-accent);\n color: var(--sc-onAccent);\n font-weight: 600;\n}\nbutton.primary:hover:not(:disabled) {\n filter: brightness(1.08);\n background: var(--sc-accent);\n}\n:focus-visible {\n outline: 2px solid var(--sc-accent);\n outline-offset: 2px;\n}\nmain {\n height: 100vh;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n}\nh1,\nh2,\np {\n margin: 0;\n}\nh1 {\n font-size: 15px;\n font-weight: 600;\n}\nh2 {\n font-size: 13px;\n font-weight: 600;\n}\nsvg {\n width: 22px;\n height: 22px;\n flex-shrink: 0;\n}\n.app-header {\n display: flex;\n gap: 20px;\n align-items: center;\n justify-content: space-between;\n min-height: 76px;\n padding: 14px 20px;\n border-bottom: 1px solid var(--sc-border);\n}\n.app-heading,\n.header-actions {\n display: flex;\n align-items: center;\n gap: 12px;\n min-width: 0;\n}\n.app-heading p {\n color: var(--sc-muted);\n font-size: 12px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n max-width: 50vw;\n}\n.app-icon {\n display: flex;\n align-items: center;\n justify-content: center;\n color: var(--sc-accent);\n width: 38px;\n height: 38px;\n border-radius: 10px;\n background: var(--sc-selection);\n flex-shrink: 0;\n}\n.connection-state {\n font-size: 12px;\n color: var(--sc-muted);\n white-space: nowrap;\n}\n.connection-state::before {\n content: \"\";\n display: inline-block;\n width: 6px;\n height: 6px;\n margin-right: 7px;\n border-radius: 50%;\n background: currentColor;\n}\n.connection-state.connected {\n color: var(--sc-success);\n}\n.setup {\n display: grid;\n place-items: center;\n flex: 1;\n overflow: auto;\n padding: 28px;\n}\n.setup-card {\n text-align: center;\n max-width: 490px;\n}\n.setup-icon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 64px;\n height: 64px;\n border: 1px solid var(--sc-border);\n border-radius: 16px;\n color: var(--sc-accent);\n background: var(--sc-raised);\n margin-bottom: 20px;\n}\n.setup-icon svg {\n width: 32px;\n height: 32px;\n}\n.setup h2 {\n font-size: 23px;\n font-weight: 600;\n margin-bottom: 10px;\n}\n.setup p {\n color: var(--sc-muted);\n line-height: 1.7;\n}\n.provider-list {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n justify-content: center;\n margin: 22px 0;\n}\n.provider-list span {\n font-size: 11px;\n border: 1px solid var(--sc-border);\n padding: 4px 9px;\n border-radius: 5px;\n color: var(--sc-muted);\n}\n.setup button {\n margin: 0 3px;\n}\n.setup .setup-note {\n font-size: 12px;\n margin: 24px auto 0;\n max-width: 360px;\n}\n.workspace {\n display: grid;\n grid-template-columns: 210px minmax(0, 1fr);\n min-height: 0;\n flex: 1;\n}\naside {\n background: var(--sc-inset);\n border-right: 1px solid var(--sc-border);\n padding: 16px 12px;\n overflow: auto;\n}\n.side-head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 10px;\n padding: 0 6px 12px;\n}\n.side-head h2 {\n color: var(--sc-muted);\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n}\n.icon-button {\n padding: 0;\n min-height: 28px;\n width: 28px;\n font-size: 18px;\n border-color: transparent;\n}\n#tree {\n color: var(--sc-muted);\n font-size: 12px;\n}\ndetails {\n margin: 4px 0;\n}\nsummary {\n padding: 5px 6px;\n cursor: pointer;\n color: var(--sc-text);\n}\ndetails p {\n padding: 6px 16px;\n}\n.table {\n display: block;\n border: 0;\n width: 100%;\n text-align: left;\n min-height: 30px;\n padding: 5px 12px 5px 25px;\n font-size: 12px;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n.work {\n padding: 18px;\n display: flex;\n flex-direction: column;\n gap: 10px;\n min-width: 0;\n min-height: 0;\n}\n.editor-heading {\n display: flex;\n align-items: center;\n justify-content: space-between;\n font-size: 12px;\n}\n.editor-heading span {\n font-size: 11px;\n color: var(--sc-muted);\n}\ntextarea {\n width: 100%;\n min-height: 90px;\n height: 150px;\n max-height: 50vh;\n flex-shrink: 0;\n resize: vertical;\n border: 1px solid var(--sc-border);\n border-radius: 6px;\n background: var(--sc-inset);\n color: var(--sc-text);\n padding: 12px 14px;\n font:\n 13px/1.7 Consolas,\n \"Cascadia Code\",\n monospace;\n tab-size: 2;\n}\n.toolbar,\n.result-head,\n.result-head > div,\n.pager {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.query-note {\n margin-left: auto;\n color: var(--sc-muted);\n font-size: 11px;\n}\n.result-head {\n justify-content: space-between;\n margin-top: 10px;\n}\n.safe {\n font-size: 11px;\n color: var(--sc-muted);\n display: inline-flex;\n align-items: center;\n gap: 5px;\n}\ninput[type=\"checkbox\"] {\n accent-color: var(--sc-accent);\n}\n.grid-wrap {\n overflow: auto;\n min-height: 100px;\n flex: 1;\n border: 1px solid var(--sc-border);\n border-radius: 6px;\n background: var(--sc-inset);\n}\ntable {\n border-collapse: separate;\n border-spacing: 0;\n min-width: 100%;\n font:\n 12px/1.5 Consolas,\n monospace;\n}\nth,\ntd {\n padding: 8px 12px;\n border-right: 1px solid var(--sc-border);\n border-bottom: 1px solid var(--sc-border);\n white-space: pre;\n max-width: 420px;\n overflow: hidden;\n text-overflow: ellipsis;\n text-align: left;\n}\nth {\n position: sticky;\n top: 0;\n background: var(--sc-raised);\n color: var(--sc-muted);\n font-weight: 500;\n}\ntr:hover td {\n background: var(--sc-hover);\n}\n.cell-null {\n color: var(--sc-muted);\n font-style: italic;\n}\n.cell-binary {\n color: var(--sc-accent);\n}\n.cell-unsupported,\n#warnings {\n color: var(--sc-warning);\n}\n#warnings {\n font-size: 12px;\n}\n#empty {\n padding: 28px;\n text-align: center;\n color: var(--sc-muted);\n font-size: 12px;\n}\n.pager {\n justify-content: flex-end;\n font-size: 11px;\n color: var(--sc-muted);\n}\n.pager #page {\n margin-right: auto;\n}\n.pager button {\n font-size: 11px;\n min-height: 28px;\n padding: 4px 10px;\n}\nfooter {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n min-height: 32px;\n padding: 6px 16px;\n border-top: 1px solid var(--sc-border);\n color: var(--sc-muted);\n font-size: 11px;\n}\nfooter > :first-child {\n overflow-wrap: anywhere;\n}\nfooter > :last-child {\n white-space: nowrap;\n}\ndialog {\n color: var(--sc-text);\n background: var(--sc-surface);\n border: 1px solid var(--sc-border);\n border-radius: 14px;\n padding: 26px;\n width: min(520px, calc(100vw - 32px));\n max-height: calc(100vh - 32px);\n overflow: auto;\n box-shadow: 0 20px 70px #0006;\n}\ndialog::backdrop {\n background: #0007;\n}\n.dialog-heading {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 14px;\n}\ndialog h2 {\n font-size: 23px;\n margin-bottom: 8px;\n}\ndialog p {\n color: var(--sc-muted);\n font-size: 12px;\n line-height: 1.6;\n}\nfieldset {\n border: 0;\n margin: 18px 0 0;\n padding: 0;\n min-width: 0;\n}\nfieldset label {\n display: flex;\n flex-direction: column;\n gap: 6px;\n font-size: 12px;\n margin-bottom: 12px;\n}\nfieldset input:not([type=\"checkbox\"]),\nselect {\n width: 100%;\n min-width: 0;\n padding: 9px 10px;\n border: 1px solid var(--sc-border);\n border-radius: 6px;\n background: var(--sc-raised);\n color: var(--sc-text);\n}\n.field-row {\n display: flex;\n gap: 12px;\n}\n.field-row label {\n flex: 1;\n min-width: 0;\n}\n.field-row .port-field {\n flex: 0 0 100px;\n}\nfieldset .checkbox {\n flex-direction: row;\n align-items: center;\n gap: 7px;\n margin: 10px 0;\n}\n.field-hint {\n margin: 10px 0;\n}\n.dialog-actions {\n display: flex;\n gap: 8px;\n align-items: center;\n margin-top: 20px;\n}\n.dialog-actions span {\n margin-right: auto;\n font-size: 11px;\n color: var(--sc-muted);\n}\n.error {\n margin-top: 14px;\n padding: 10px 12px;\n border-radius: 6px;\n background: var(--sc-dangerSoft);\n color: var(--sc-danger);\n}\n@media (max-width: 700px) {\n .app-header {\n padding: 12px;\n gap: 10px;\n }\n .connection-state {\n display: none;\n }\n .workspace {\n grid-template-columns: 155px minmax(0, 1fr);\n }\n .work {\n padding: 12px;\n }\n .query-note {\n display: none;\n }\n .result-head {\n align-items: flex-start;\n gap: 8px;\n flex-wrap: wrap;\n }\n .safe {\n font-size: 10px;\n }\n footer > :last-child {\n display: none;\n }\n}\n@media (max-width: 480px) {\n .workspace {\n grid-template-columns: 1fr;\n }\n aside {\n max-height: 145px;\n border-right: 0;\n border-bottom: 1px solid var(--sc-border);\n }\n .app-heading p {\n max-width: 40vw;\n }\n .app-header .app-icon {\n display: none;\n }\n .field-row {\n flex-wrap: wrap;\n }\n}\n"} \ No newline at end of file +{"format":1,"kind":"app","id":"dev.shellcanvas.database","version":"0.1.1","title":"ShellCanvas Database","description":"Browse database schemas, run SQL, inspect typed results and export CSV.","permissions":["services.dev.shellcanvas.database","host.tcp"],"script":"\"use strict\";\n(() => {\n // node_modules/@techartdev/shellcanvas-app-sdk/dist/rpc.js\n var RpcError = class extends Error {\n code;\n constructor(code, message2) {\n super(message2);\n this.code = code;\n this.name = \"RpcError\";\n }\n };\n var codes = [\n \"invalid\",\n \"closed\",\n \"aborted\",\n \"denied\",\n \"unavailable\",\n \"busy\",\n \"failed\"\n ];\n var maxMessage = 4 * 1024 * 1024;\n var maxPending = 64;\n var methodName = /^[a-z][a-z0-9-]*(?:\\.[a-zA-Z][a-zA-Z0-9-]*)+$/;\n function isJsonValue(value) {\n const pending = [{ value, depth: 0 }];\n while (pending.length) {\n const item = pending.pop();\n if (item.depth > 64)\n return false;\n if (item.value === null || typeof item.value === \"string\" || typeof item.value === \"boolean\")\n continue;\n if (typeof item.value === \"number\" && Number.isFinite(item.value))\n continue;\n if (typeof item.value !== \"object\" || item.value === null)\n return false;\n if (!Array.isArray(item.value) && Object.getPrototypeOf(item.value) !== Object.prototype)\n return false;\n for (const child of Object.values(item.value))\n pending.push({ value: child, depth: item.depth + 1 });\n }\n return true;\n }\n function encode(value) {\n if (!isJsonValue(value))\n throw new RpcError(\"invalid\", \"Only finite JSON values with at most 64 nesting levels are supported.\");\n const text = JSON.stringify(value);\n if (text.length > maxMessage)\n throw new RpcError(\"invalid\", \"RPC message exceeds 4 Mi UTF-16 units; stream bulk data separately.\");\n return text;\n }\n function decode(raw) {\n if (typeof raw !== \"string\" || raw.length > maxMessage)\n throw new RpcError(\"invalid\", \"Invalid RPC message.\");\n const value = JSON.parse(raw);\n if (!isJsonValue(value) || !value || typeof value !== \"object\" || Array.isArray(value) || value.v !== 1)\n throw new RpcError(\"invalid\", \"Unsupported RPC envelope.\");\n if (value.type === \"close\")\n return { v: 1, type: \"close\" };\n const id = value.id;\n if (typeof id !== \"number\" || !Number.isSafeInteger(id) || id < 1)\n throw new RpcError(\"invalid\", \"Invalid request identity.\");\n if (value.type === \"request\" && typeof value.method === \"string\" && methodName.test(value.method) && Object.hasOwn(value, \"params\"))\n return {\n v: 1,\n type: \"request\",\n id,\n method: value.method,\n params: value.params\n };\n if (value.type === \"cancel\")\n return { v: 1, type: \"cancel\", id };\n if (value.type === \"result\" && Object.hasOwn(value, \"value\"))\n return { v: 1, type: \"result\", id, value: value.value };\n if (value.type === \"error\" && typeof value.code === \"string\" && codes.includes(value.code) && typeof value.message === \"string\")\n return {\n v: 1,\n type: \"error\",\n id,\n code: value.code,\n message: value.message\n };\n throw new RpcError(\"invalid\", \"Invalid RPC envelope.\");\n }\n var RpcPeer = class {\n transport;\n closed = false;\n serial = 0;\n received = 0;\n unsubscribe = () => {\n };\n pending = /* @__PURE__ */ new Map();\n running = /* @__PURE__ */ new Map();\n methods;\n grants;\n closeListeners = /* @__PURE__ */ new Set();\n constructor(transport, methods = /* @__PURE__ */ new Map(), grants = []) {\n this.transport = transport;\n this.methods = new Map([...methods].map(([name, method]) => {\n if (!methodName.test(name))\n throw new RpcError(\"invalid\", `Invalid method name: ${name}`);\n return [\n name,\n {\n grants: [...method.grants],\n invoke: method.invoke,\n available: method.available\n }\n ];\n }));\n this.grants = new Set(grants);\n this.unsubscribe = transport.subscribe((raw) => this.receive(raw), () => this.finish(new RpcError(\"closed\", \"Extension channel disconnected.\")));\n }\n get isClosed() {\n return this.closed;\n }\n onClose(listener) {\n if (this.closed) {\n listener();\n return () => {\n };\n }\n this.closeListeners.add(listener);\n return () => {\n this.closeListeners.delete(listener);\n };\n }\n call(method, params = null, signal) {\n if (this.closed)\n return Promise.reject(new RpcError(\"closed\", \"Extension instance has closed.\"));\n if (signal?.aborted)\n return Promise.reject(new RpcError(\"aborted\", \"Request canceled.\"));\n if (!methodName.test(method))\n return Promise.reject(new RpcError(\"invalid\", \"Invalid method name.\"));\n if (this.pending.size >= maxPending)\n return Promise.reject(new RpcError(\"busy\", \"Too many pending requests.\"));\n const id = ++this.serial;\n if (!Number.isSafeInteger(id)) {\n this.close();\n return Promise.reject(new RpcError(\"closed\", \"Request identities exhausted.\"));\n }\n return new Promise((resolve, reject) => {\n const abort = () => {\n const request = this.pending.get(id);\n if (!request)\n return;\n this.pending.delete(id);\n request.cleanup();\n reject(new RpcError(\"aborted\", \"Request canceled; an already dispatched write may have completed.\"));\n this.send({ v: 1, type: \"cancel\", id });\n };\n this.pending.set(id, {\n resolve,\n reject,\n cleanup: () => signal?.removeEventListener(\"abort\", abort)\n });\n signal?.addEventListener(\"abort\", abort, { once: true });\n try {\n this.transport.send(encode({ v: 1, type: \"request\", id, method, params }));\n } catch (error) {\n this.pending.get(id)?.cleanup();\n this.pending.delete(id);\n reject(error instanceof RpcError ? error : new RpcError(\"closed\", \"Unable to send request.\"));\n if (!(error instanceof RpcError))\n this.close();\n }\n });\n }\n /** Revocation retires the whole instance, so no outstanding operation keeps an old grant. */\n close() {\n if (this.closed)\n return;\n this.send({ v: 1, type: \"close\" });\n this.finish(new RpcError(\"closed\", \"Extension instance has closed.\"));\n }\n finish(reason) {\n if (this.closed)\n return;\n this.closed = true;\n this.unsubscribe();\n for (const request of this.pending.values()) {\n request.cleanup();\n request.reject(reason);\n }\n this.pending.clear();\n for (const controller of this.running.values())\n controller.abort();\n this.running.clear();\n for (const listener of this.closeListeners) {\n try {\n listener();\n } catch {\n }\n }\n this.closeListeners.clear();\n this.transport.close();\n }\n send(value) {\n if (this.closed)\n return;\n try {\n this.transport.send(encode(value));\n } catch {\n this.finish(new RpcError(\"closed\", \"Extension channel failed.\"));\n }\n }\n receive(raw) {\n if (this.closed)\n return;\n let value;\n try {\n value = decode(raw);\n } catch {\n this.close();\n return;\n }\n if (value.type === \"close\") {\n this.finish(new RpcError(\"closed\", \"Extension instance has closed.\"));\n return;\n }\n if (value.type === \"cancel\") {\n this.running.get(value.id)?.abort();\n return;\n }\n if (value.type === \"result\" || value.type === \"error\") {\n const request = this.pending.get(value.id);\n if (!request)\n return;\n this.pending.delete(value.id);\n request.cleanup();\n if (value.type === \"result\")\n request.resolve(value.value);\n else\n request.reject(new RpcError(value.code, value.message));\n return;\n }\n if (value.id <= this.received) {\n this.close();\n return;\n }\n this.received = value.id;\n void this.dispatch(value);\n }\n async dispatch(request) {\n const fail = (code, message2) => this.send({ v: 1, type: \"error\", id: request.id, code, message: message2 });\n const method = this.methods.get(request.method);\n if (!method) {\n fail(\"unavailable\", \"Unknown service method.\");\n return;\n }\n if (method.grants.some((grant) => !this.grants.has(grant))) {\n fail(\"denied\", \"The extension does not hold the required permission.\");\n return;\n }\n if (this.running.size >= maxPending) {\n fail(\"busy\", \"Too many active requests.\");\n return;\n }\n const controller = new AbortController();\n this.running.set(request.id, controller);\n try {\n if (method.available && !method.available())\n throw new RpcError(\"unavailable\", \"This service is currently unavailable.\");\n const result = await method.invoke(request.params, controller.signal);\n if (!this.closed && !controller.signal.aborted) {\n encode({ v: 1, type: \"result\", id: request.id, value: result });\n this.send({ v: 1, type: \"result\", id: request.id, value: result });\n }\n } catch (error) {\n if (!controller.signal.aborted && !this.closed)\n fail(error instanceof RpcError ? error.code : \"failed\", error instanceof RpcError ? error.message : \"Service operation failed.\");\n } finally {\n this.running.delete(request.id);\n }\n }\n };\n function messagePortTransport(port) {\n return {\n send: (message2) => port.postMessage(message2),\n subscribe(receive, disconnected) {\n const message2 = (event) => receive(event.data);\n port.addEventListener(\"message\", message2);\n port.addEventListener(\"messageerror\", disconnected);\n port.start();\n return () => {\n port.removeEventListener(\"message\", message2);\n port.removeEventListener(\"messageerror\", disconnected);\n };\n },\n close: () => port.close()\n };\n }\n\n // node_modules/@techartdev/shellcanvas-app-sdk/dist/network-client.js\n function appNetworkClient(peer) {\n return {\n profile: async (slot, signal) => await peer.call(\"system.network.profile\", { slot }, signal),\n configure: async (options, signal) => await peer.call(\"system.network.configure\", {\n slot: options.slot,\n suggestedEndpoint: options.suggestedEndpoint ?? \"\"\n }, signal),\n forget: async (slot, signal) => {\n await peer.call(\"system.network.forget\", { slot }, signal);\n },\n async postJSON(options, signal) {\n const id = crypto.randomUUID();\n let closed2 = false, reading = false;\n let closing;\n const close = () => {\n closed2 = true;\n signal?.removeEventListener(\"abort\", abort);\n return closing ??= peer.call(\"system.network.close\", { id }).then(() => {\n });\n };\n const abort = () => {\n void close().catch(() => {\n });\n };\n signal?.throwIfAborted();\n const starting = peer.call(\"system.network.start\", { id, ...options }, signal);\n signal?.addEventListener(\"abort\", abort, { once: true });\n try {\n const head = await starting;\n if (closed2 || signal?.aborted)\n throw new RpcError(\"aborted\", \"HTTP request canceled.\");\n return {\n ...head,\n close,\n async read(control) {\n if (closed2)\n throw new RpcError(\"closed\", \"HTTP response closed.\");\n if (reading)\n throw new RpcError(\"busy\", \"Wait for the current response read.\");\n reading = true;\n control?.addEventListener(\"abort\", abort, { once: true });\n try {\n control?.throwIfAborted();\n const bytes = await peer.call(\"system.network.read\", { id }, control);\n if (bytes === null) {\n await close();\n return null;\n }\n return Uint8Array.from(bytes);\n } catch (error) {\n abort();\n throw error;\n } finally {\n reading = false;\n control?.removeEventListener(\"abort\", abort);\n }\n }\n };\n } catch (error) {\n abort();\n throw error;\n }\n }\n };\n }\n\n // node_modules/@techartdev/shellcanvas-app-sdk/dist/file-client.js\n function appFileClient(peer) {\n return Object.freeze({\n makeDirectory: async ({ binding, parent, name }, signal) => await peer.call(\"system.files.makeDirectory\", { binding, parent, name }, signal),\n renameEntry: async ({ binding, path, revision }, name, signal) => await peer.call(\"system.files.renameEntry\", { binding, path, revision, name }, signal),\n moveEntry: async ({ binding, path, revision }, parent, signal) => await peer.call(\"system.files.moveEntry\", {\n binding,\n path,\n revision,\n parent: parent.path,\n parentBinding: parent.binding\n }, signal),\n removeEntry: async ({ binding, path, revision }, signal) => {\n await peer.call(\"system.files.removeEntry\", { binding, path, revision }, signal);\n },\n async *list({ binding, path }, signal) {\n const id = crypto.randomUUID();\n let closing;\n const close = () => closing ??= peer.call(\"system.files.listClose\", { id }).catch(() => {\n });\n const cancel = () => {\n void close();\n };\n signal?.addEventListener(\"abort\", cancel, { once: true });\n try {\n let result = await peer.call(\"system.files.listStart\", { id, binding, path: path ?? null }, signal);\n while (true) {\n yield result.directory;\n if (result.done)\n break;\n result = await peer.call(\"system.files.listNext\", { id }, signal);\n }\n } finally {\n signal?.removeEventListener(\"abort\", cancel);\n await close();\n }\n },\n readText: async ({ binding, path }, signal) => await peer.call(\"system.files.readText\", { binding, path }, signal),\n saveText: async ({ binding, path, revision }, text, signal) => await peer.call(\"system.files.saveText\", { binding, path, revision, text }, signal),\n createText: async ({ binding, parent, name, text }, signal) => await peer.call(\"system.files.createText\", { binding, parent, name, text }, signal)\n });\n }\n\n // node_modules/@techartdev/shellcanvas-app-sdk/dist/console-client.js\n function appConsoleClient(peer) {\n return {\n async open(options, signal) {\n const id = crypto.randomUUID();\n let closed2 = false;\n let closing;\n let reading = false;\n let writing = false;\n let stopPeer = () => {\n };\n const close = () => {\n if (closing)\n return closing;\n closed2 = true;\n stopPeer();\n signal?.removeEventListener(\"abort\", abort);\n closing = peer.call(\"system.console.close\", { id }).then(() => {\n });\n return closing;\n };\n const abort = () => {\n void close().catch(() => {\n });\n };\n stopPeer = peer.onClose(() => {\n closed2 = true;\n signal?.removeEventListener(\"abort\", abort);\n closing ??= Promise.resolve();\n });\n if (signal?.aborted) {\n stopPeer();\n throw new RpcError(\"aborted\", \"Console opening canceled.\");\n }\n const opening = peer.call(\"system.console.open\", {\n id,\n binding: options.binding,\n cols: options.cols ?? 80,\n rows: options.rows ?? 24\n }, signal);\n signal?.addEventListener(\"abort\", abort, { once: true });\n try {\n const result = await opening;\n if (closed2 || signal?.aborted)\n throw new RpcError(\"aborted\", \"Console opening canceled.\");\n const call2 = async (method, params, control) => {\n if (closed2)\n throw new RpcError(\"closed\", \"Console is closed.\");\n const onAbort = () => {\n void close().catch(() => {\n });\n };\n control?.addEventListener(\"abort\", onAbort, { once: true });\n try {\n if (control?.aborted) {\n onAbort();\n throw new RpcError(\"aborted\", \"Console operation canceled; the console was closed.\");\n }\n return await peer.call(method, params, control);\n } finally {\n control?.removeEventListener(\"abort\", onAbort);\n }\n };\n return {\n binding: result.binding,\n resizable: result.resizable,\n close,\n async read(control) {\n if (reading)\n throw new RpcError(\"busy\", \"Wait for the current console read.\");\n reading = true;\n try {\n const value = await call2(\"system.console.read\", { id }, control);\n return value === null ? null : Uint8Array.from(value.bytes);\n } finally {\n reading = false;\n }\n },\n async write(data, control) {\n if (writing)\n throw new RpcError(\"busy\", \"Wait for the current console write.\");\n const bytes = typeof data === \"string\" ? new TextEncoder().encode(data) : new Uint8Array(data);\n writing = true;\n try {\n for (let offset = 0; offset < bytes.length || offset === 0; offset += 65536)\n await call2(\"system.console.write\", {\n id,\n bytes: Array.from(bytes.subarray(offset, offset + 65536))\n }, control);\n } finally {\n writing = false;\n }\n },\n async resize(cols, rows, control) {\n if (!result.resizable)\n throw new RpcError(\"unavailable\", \"This console has fixed dimensions.\");\n await call2(\"system.console.resize\", { id, cols, rows }, control);\n }\n };\n } catch (error) {\n void close().catch(() => {\n });\n throw error;\n }\n }\n };\n }\n\n // node_modules/@techartdev/shellcanvas-app-sdk/dist/transfer-client.js\n function appTransferClient(peer) {\n const handle = (item) => {\n let closed2 = false;\n let running;\n let closing;\n const call2 = (method, extra = {}, signal) => {\n if (closed2)\n return Promise.reject(new RpcError(\"closed\", \"Transfer handle is closed.\"));\n return peer.call(`system.transfers.${method}`, { id: item.id, ...extra }, signal);\n };\n const cancel = async () => {\n await call2(\"cancel\");\n };\n return {\n binding: item.binding,\n name: item.name,\n size: item.size,\n direction: item.direction,\n async run(signal) {\n const abort = () => {\n void cancel().catch(() => {\n });\n };\n signal?.addEventListener(\"abort\", abort, { once: true });\n try {\n if (signal?.aborted)\n await cancel();\n running ??= call2(\"run\");\n return await running;\n } finally {\n signal?.removeEventListener(\"abort\", abort);\n }\n },\n async status() {\n return await call2(\"status\", {\n after: null\n });\n },\n async *watch(signal) {\n let after = null;\n for (; ; ) {\n const snapshot = await call2(\"status\", { after }, signal);\n yield snapshot;\n if (snapshot.result)\n return;\n after = snapshot.revision;\n }\n },\n cancel,\n close() {\n if (closing)\n return closing;\n closing = call2(\"close\").then(() => {\n closed2 = true;\n }, (error) => {\n closing = void 0;\n throw error;\n });\n return closing;\n }\n };\n };\n async function prepare(method, options, signal) {\n const id = crypto.randomUUID();\n try {\n const items = await peer.call(`system.transfers.${method}`, { id, ...options }, signal);\n if (signal?.aborted)\n throw new RpcError(\"aborted\", \"Transfer preparation canceled.\");\n return items.map(handle);\n } catch (error) {\n void peer.call(\"system.transfers.cancelPreparation\", { id }).catch(() => {\n });\n throw error;\n }\n }\n return {\n pasteClipboard: async (destination2, signal) => {\n const snapshot = await peer.call(\"system.transfers.clipboardInspect\", { binding: destination2.binding }, signal);\n if (snapshot.kind === \"empty\")\n return [];\n return prepare(snapshot.sequence === null ? \"clipboardPaste\" : snapshot.kind === \"remote\" ? snapshot.intent === \"move\" ? \"clipboardMoveSnapshot\" : \"clipboardCopySnapshot\" : \"clipboardPasteSnapshot\", {\n binding: destination2.binding,\n parent: destination2.path,\n ...snapshot.sequence === null ? {} : { sequence: snapshot.sequence }\n }, signal);\n },\n upload: (destination2, options, signal) => prepare(options?.folder ? \"uploadFolder\" : \"upload\", { binding: destination2.binding, parent: destination2.path }, signal),\n async download(entry, signal) {\n return (await prepare(\"download\", {\n binding: entry.binding,\n path: entry.path,\n revision: entry.revision\n }, signal))[0] ?? null;\n },\n downloadMany: (entries, signal) => prepare(\"downloadMany\", {\n binding: entries[0]?.binding ?? \"\",\n entries: entries.map((entry) => ({\n binding: entry.binding,\n path: entry.path,\n revision: entry.revision\n }))\n }, signal),\n async copy(entry, destination2, signal) {\n return (await prepare(\"copy\", {\n binding: entry.binding,\n path: entry.path,\n revision: entry.revision,\n parent: destination2.path,\n parentBinding: destination2.binding\n }, signal))[0];\n }\n };\n }\n\n // node_modules/@techartdev/shellcanvas-app-sdk/dist/host-settings-client.js\n function appHostSettingsClient(peer) {\n return {\n read: async (location, signal) => await peer.call(\"system.hostSettings.read\", { binding: location.binding }, signal),\n apply: async (setting, value, signal) => await peer.call(\"system.hostSettings.apply\", {\n binding: setting.binding,\n id: setting.id,\n revision: setting.revision,\n value\n }, signal)\n };\n }\n\n // node_modules/@techartdev/shellcanvas-app-sdk/dist/events-client.js\n function appEventClient(peer) {\n const listeners = /* @__PURE__ */ new Set();\n const latest = /* @__PURE__ */ new Map();\n let cursor = null, controller;\n const error = (listener, failure) => {\n try {\n listener.error?.(failure);\n } catch {\n }\n };\n const deliver = (listener, batch) => {\n if (listeners.has(listener))\n try {\n void Promise.resolve(listener.receive(structuredClone(batch))).catch((failure) => error(listener, failure));\n } catch (failure) {\n error(listener, failure);\n }\n };\n const poll = async (active) => {\n try {\n while (listeners.size && controller === active) {\n const batch = await peer.call(\"system.events.next\", { after: cursor }, active.signal);\n if (controller !== active || active.signal.aborted)\n return;\n cursor = batch.cursor;\n if (batch.reset)\n latest.clear();\n for (const event of batch.events)\n latest.set(event.topic, event);\n for (const listener of [...listeners])\n deliver(listener, batch);\n }\n } catch (failure) {\n if (controller !== active || active.signal.aborted)\n return;\n const stopped = [...listeners];\n listeners.clear();\n controller = void 0;\n latest.clear();\n cursor = null;\n for (const listener of stopped)\n error(listener, failure);\n }\n };\n return Object.freeze({\n subscribe(receive, onError) {\n const listener = { receive, error: onError };\n listeners.add(listener);\n if (!controller) {\n controller = new AbortController();\n void poll(controller);\n } else if (cursor !== null) {\n const snapshot = {\n cursor,\n reset: true,\n events: [...latest.values()].sort((a, b) => a.sequence - b.sequence)\n };\n queueMicrotask(() => deliver(listener, snapshot));\n }\n return () => {\n listeners.delete(listener);\n if (!listeners.size) {\n const previous = controller;\n controller = void 0;\n previous?.abort();\n cursor = null;\n latest.clear();\n }\n };\n }\n });\n }\n\n // node_modules/@techartdev/shellcanvas-app-sdk/dist/clipboard-image.js\n var imageChunkSize = 32 * 1024;\n function imageByteLength(width, height) {\n if (typeof width !== \"number\" || typeof height !== \"number\" || !Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0 || width > 4294967295 || height > 4294967295 || !Number.isSafeInteger(width * height * 4))\n throw new RpcError(\"invalid\", \"Provide positive image dimensions with a representable RGBA length.\");\n return width * height * 4;\n }\n function imageBytes(value) {\n return Array.isArray(value) && value.length > 0 && value.length <= imageChunkSize && value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255);\n }\n function appImageClipboardClient(peer) {\n const release = (id) => peer.call(\"system.clipboard.image.release\", { id }).catch(() => {\n });\n return {\n async readImage(signal) {\n const id = crypto.randomUUID();\n try {\n const meta = await peer.call(\"system.clipboard.image.readStart\", { id }, signal);\n const length = imageByteLength(meta?.width, meta?.height);\n const rgba = new Uint8Array(length);\n let offset = 0;\n while (offset < length) {\n const bytes = await peer.call(\"system.clipboard.image.readChunk\", { id, offset }, signal);\n if (!imageBytes(bytes) || offset + bytes.length > length)\n throw new RpcError(\"failed\", \"Invalid clipboard image stream.\");\n rgba.set(bytes, offset);\n offset += bytes.length;\n }\n return { width: meta.width, height: meta.height, rgba };\n } finally {\n await release(id);\n }\n },\n async writeImage(image, signal) {\n const length = imageByteLength(image?.width, image?.height);\n if (!(image.rgba instanceof Uint8Array) || image.rgba.length !== length)\n throw new RpcError(\"invalid\", \"Image dimensions must match the RGBA bytes.\");\n const rgba = image.rgba.slice(), width = image.width, height = image.height;\n const id = crypto.randomUUID();\n try {\n await peer.call(\"system.clipboard.image.writeStart\", { id, width, height }, signal);\n for (let offset = 0; offset < length; offset += imageChunkSize)\n await peer.call(\"system.clipboard.image.writeChunk\", {\n id,\n offset,\n bytes: Array.from(rgba.subarray(offset, offset + imageChunkSize))\n }, signal);\n await peer.call(\"system.clipboard.image.writeCommit\", { id }, signal);\n } finally {\n await release(id);\n }\n }\n };\n }\n\n // node_modules/@techartdev/shellcanvas-app-sdk/dist/clipboard-client.js\n var chunkSize = 64 * 1024;\n function appClipboardClient(peer) {\n const release = (id) => peer.call(\"system.clipboard.release\", { id }).catch(() => {\n });\n return Object.freeze({\n async cutFile(entry, signal) {\n if (!entry || typeof entry.binding !== \"string\" || !entry.binding || typeof entry.path !== \"string\" || !entry.path || typeof entry.revision !== \"string\" || !entry.revision)\n throw new RpcError(\"invalid\", \"Cut a current file entry from an accepted workspace binding.\");\n const { binding, path, revision } = entry;\n const id = crypto.randomUUID();\n try {\n await peer.call(\"system.clipboard.files.cut\", { id, binding, path, revision }, signal);\n } finally {\n await peer.call(\"system.clipboard.files.release\", { id }).catch(() => {\n });\n }\n },\n async copyFiles(entries, signal) {\n const snapshot = entries.map(({ binding, path, revision }) => ({\n binding,\n path,\n revision\n }));\n if (!snapshot.length || snapshot.some((entry) => entry.binding !== snapshot[0].binding || !entry.path || !entry.revision))\n throw new RpcError(\"invalid\", \"Copy current file entries from one workspace binding.\");\n const id = crypto.randomUUID();\n try {\n await peer.call(\"system.clipboard.files.start\", { id, binding: snapshot[0].binding }, signal);\n for (let offset = 0; offset < snapshot.length; ) {\n const chunk = [];\n let size = 0;\n while (offset + chunk.length < snapshot.length && chunk.length < 128) {\n const { path, revision } = snapshot[offset + chunk.length];\n const entry = { path, revision };\n const length = JSON.stringify(entry).length;\n if (chunk.length && size + length > 64 * 1024)\n break;\n chunk.push(entry);\n size += length;\n }\n await peer.call(\"system.clipboard.files.append\", {\n id,\n offset,\n entries: chunk\n }, signal);\n offset += chunk.length;\n }\n await peer.call(\"system.clipboard.files.commit\", { id }, signal);\n } finally {\n await peer.call(\"system.clipboard.files.release\", { id }).catch(() => {\n });\n }\n },\n pasteFiles: appTransferClient(peer).pasteClipboard,\n ...appImageClipboardClient(peer),\n async readText(signal) {\n const id = crypto.randomUUID();\n const chunks = [];\n try {\n const start2 = await peer.call(\"system.clipboard.readStart\", { id }, signal);\n let offset = 0;\n while (offset < start2.length) {\n const chunk = await peer.call(\"system.clipboard.readChunk\", { id: start2.id, offset }, signal);\n if (typeof chunk !== \"string\" || !chunk.length || offset + chunk.length > start2.length)\n throw new RpcError(\"failed\", \"Invalid clipboard text stream.\");\n chunks.push(chunk);\n offset += chunk.length;\n }\n return chunks.join(\"\");\n } finally {\n await release(id);\n }\n },\n async writeText(text, signal) {\n if (typeof text !== \"string\")\n throw new RpcError(\"invalid\", \"Clipboard text must be a string.\");\n const id = crypto.randomUUID();\n try {\n await peer.call(\"system.clipboard.writeStart\", { id, length: text.length }, signal);\n for (let offset = 0; offset < text.length; offset += chunkSize)\n await peer.call(\"system.clipboard.writeChunk\", { id, offset, text: text.slice(offset, offset + chunkSize) }, signal);\n await peer.call(\"system.clipboard.writeCommit\", { id }, signal);\n } finally {\n await release(id);\n }\n }\n });\n }\n\n // node_modules/@techartdev/shellcanvas-app-sdk/dist/client.js\n function connectToShellCanvas(timeoutMs = 1e4) {\n return new Promise((resolve, reject) => {\n const token = document.querySelector('meta[name=\"shellcanvas-instance\"]')?.content;\n if (!token) {\n reject(new Error(\"Missing ShellCanvas instance identity.\"));\n return;\n }\n if (window.parent === window) {\n reject(new Error(\"Launch this app inside ShellCanvas.\"));\n return;\n }\n const timer = setTimeout(() => {\n window.removeEventListener(\"message\", receive);\n reject(new Error(\"ShellCanvas did not establish an app channel.\"));\n }, timeoutMs);\n const receive = (event) => {\n if (event.source !== window.parent || event.data?.type !== \"shellcanvas:connect:v1\" || event.data?.token !== token || event.ports.length !== 1)\n return;\n clearTimeout(timer);\n window.removeEventListener(\"message\", receive);\n const peer = new RpcPeer(messagePortTransport(event.ports[0]));\n let lastFocus = document.activeElement;\n const rememberFocus = (event2) => {\n if (event2.target instanceof HTMLElement && event2.target !== document.body)\n lastFocus = event2.target;\n };\n document.addEventListener(\"focusin\", rememberFocus);\n const dispose = () => {\n document.removeEventListener(\"focusin\", rememberFocus);\n window.removeEventListener(\"pagehide\", dispose);\n peer.close();\n };\n const call2 = async (method, params, signal) => {\n const origin = document.activeElement === document.body ? lastFocus : document.activeElement;\n try {\n return await peer.call(method, JSON.parse(JSON.stringify(params)), signal);\n } finally {\n requestAnimationFrame(() => requestAnimationFrame(() => {\n if (!peer.isClosed && origin instanceof HTMLElement && origin.isConnected)\n origin.focus();\n }));\n }\n };\n const system = Object.freeze({\n apiVersion: 1,\n dialogs: Object.freeze({\n messageBox: async (options, control) => await call2(\"system.dialogs.messageBox\", options, control?.signal),\n openFile: async (options = {}, control) => await call2(\"system.dialogs.openFile\", options, control?.signal),\n saveFile: async (options = {}, control) => await call2(\"system.dialogs.saveFile\", options, control?.signal)\n }),\n files: Object.freeze({\n saveTextAs: async (options, control) => await call2(\"system.files.saveTextAs\", options, control?.signal)\n })\n });\n window.addEventListener(\"pagehide\", dispose, { once: true });\n const storage = (namespace) => Object.freeze({\n get: async (key, signal) => await peer.call(`system.${namespace}.get`, { key }, signal),\n put: async (key, value, expectedRevision, signal) => await peer.call(`system.${namespace}.put`, { key, value, expectedRevision }, signal),\n remove: async (key, expectedRevision, signal) => {\n await peer.call(`system.${namespace}.remove`, { key, expectedRevision }, signal);\n },\n list: async (options = {}, signal) => await peer.call(`system.${namespace}.list`, JSON.parse(JSON.stringify(options)), signal)\n });\n resolve({\n network: appNetworkClient(peer),\n system,\n files: appFileClient(peer),\n console: appConsoleClient(peer),\n transfers: appTransferClient(peer),\n hostSettings: appHostSettingsClient(peer),\n storage: storage(\"storage\"),\n settings: storage(\"settings\"),\n environment: Object.freeze({\n get: async (signal) => await peer.call(\"system.environment.get\", null, signal)\n }),\n services: Object.freeze({\n call: (method, params = null, signal) => peer.call(\"system.services.call\", { method, params }, signal),\n list: async (signal) => await peer.call(\"system.services.list\", null, signal)\n }),\n events: appEventClient(peer),\n clipboard: appClipboardClient(peer),\n window: Object.freeze({\n getState: async (signal) => await peer.call(\"system.window.getState\", null, signal),\n focus: async (signal) => {\n await peer.call(\"system.window.focus\", null, signal);\n requestAnimationFrame(() => requestAnimationFrame(() => {\n if (!peer.isClosed && lastFocus instanceof HTMLElement && lastFocus.isConnected)\n lastFocus.focus({ preventScroll: true });\n }));\n },\n minimize: async (signal) => {\n await peer.call(\"system.window.minimize\", null, signal);\n },\n maximize: async (signal) => {\n await peer.call(\"system.window.maximize\", null, signal);\n },\n restore: async (signal) => {\n await peer.call(\"system.window.restore\", null, signal);\n },\n requestClose: async (signal) => {\n await peer.call(\"system.window.requestClose\", null, signal);\n },\n setDocumentState: async (state) => {\n await peer.call(\"system.window.setDocumentState\", JSON.parse(JSON.stringify(state)));\n }\n }),\n call: (method, params, signal) => peer.call(method, params, signal),\n dispose\n });\n };\n window.addEventListener(\"message\", receive);\n window.parent.postMessage({ type: \"shellcanvas:ready:v1\", token }, \"*\");\n });\n }\n\n // paging.js\n function nextOffset(page) {\n return page.offset + page.rows.length;\n }\n\n // connection-state.js\n var prefix = \"dev.shellcanvas.database.\";\n var required = [\n \"capabilities\",\n \"schemas\",\n \"tables\",\n \"query\",\n \"page\",\n \"closeResult\",\n \"exportBegin\",\n \"exportStatus\",\n \"exportCancel\"\n ];\n function connectionState(environment, methods, ownConnection = false) {\n const database = methods.filter((method) => method.name.startsWith(prefix));\n if (database.some((method) => !method.granted))\n return {\n ready: false,\n title: \"Database permission needed\",\n detail: \"Review this app in App Manager and allow its Database service permission.\"\n };\n if (!ownConnection && environment.connection === \"review-required\")\n return {\n ready: false,\n title: \"Review the reconnected host\",\n detail: \"Use the reconnected host in the bar above this app, or connect a database directly.\"\n };\n if (!ownConnection && environment.connection === \"disconnected\")\n return {\n ready: false,\n title: \"Workspace disconnected\",\n detail: \"Reconnect the workspace, or create a database connection directly from this app.\"\n };\n if (!database.length)\n return {\n ready: false,\n title: \"Connect a database\",\n detail: \"Choose a database server or an existing SQLite file to browse tables and run queries.\"\n };\n if (!required.every(\n (name) => database.some(\n (method) => method.name === prefix + name && method.available && method.granted\n )\n ))\n return {\n ready: false,\n title: \"Database service unavailable\",\n detail: \"The selected workspace does not expose all database operations. Reconnect it, or connect a database directly.\"\n };\n return {\n ready: true,\n title: \"Connected\",\n detail: \"Database services are available.\"\n };\n }\n var colors = [\n \"base\",\n \"surface\",\n \"raised\",\n \"inset\",\n \"text\",\n \"muted\",\n \"accent\",\n \"onAccent\",\n \"border\",\n \"hover\",\n \"selection\",\n \"danger\",\n \"dangerSoft\",\n \"warning\",\n \"warningSoft\",\n \"success\"\n ];\n function applyAppearance(root2, appearance) {\n if (!appearance) return;\n root2.dataset.mode = appearance.mode === \"light\" ? \"light\" : \"dark\";\n root2.style.setProperty(\"color-scheme\", root2.dataset.mode);\n for (const key of colors) {\n const value = appearance.colors[key];\n if (typeof value === \"string\" && /^#[0-9a-f]{6}$/i.test(value))\n root2.style.setProperty(`--sc-${key}`, value);\n }\n }\n\n // main.ts\n var service = \"dev.shellcanvas.database\";\n var databaseIcon = '';\n var root = document.querySelector(\"#root\");\n root.innerHTML = `\n
\n
${databaseIcon}

Database

No database connected

Not connected
\n
${databaseIcon}

Connect a database

Choose a database server or an existing SQLite file to browse tables and run queries.

SQL ServerPostgreSQLMySQL / MariaDBSQLite

Database connections use your connected SSH host. No public database port is needed.

\n \n
Checking the app connection\\u2026Database workspace
\n
\n
${databaseIcon}

Connect a database

Connect to a database on your SSH host or its network. Enter its address and credentials.

For SQL Server named instances, enter the database server and its TCP port.

Passwords are not saved.
`;\n var $ = (id) => document.getElementById(id);\n var input = (id) => $(id);\n var button = (id) => $(id);\n var sql = $(\"sql\");\n var dialog = $(\"connect-dialog\");\n var status = $(\"status\");\n var client;\n var current = null;\n var queryController = null;\n var connectController = null;\n var provider = \"\";\n var generation = 0;\n var schemaOp = 0;\n var pageOp = 0;\n var bindOp = 0;\n var exportJob = null;\n var pageBusy = false;\n var ready = false;\n var exportBusy = false;\n var previousOffsets = [];\n var signature = null;\n var privateConnection = false;\n var companionAvailable = false;\n var destination = \"\";\n var sshAvailable = false;\n var sshHostName = \"\";\n var closed = false;\n var pageSize = 100;\n function message(error) {\n return error instanceof RpcError ? error.message : error instanceof Error ? error.message : String(error);\n }\n function report(error) {\n status.textContent = message(error);\n }\n function setControls() {\n button(\"run\").disabled = !ready || !!queryController || !!connectController || exportBusy;\n button(\"cancel\").disabled = !queryController;\n button(\"reload\").disabled = !ready || !!connectController;\n button(\"export\").disabled = !ready || !current?.columns.length || exportBusy || !!queryController;\n button(\"prev\").disabled = !ready || pageBusy || !previousOffsets.length;\n button(\"next\").disabled = !ready || pageBusy || !current?.hasMore;\n button(\"disconnect\").hidden = !privateConnection || !ready;\n button(\"disconnect\").disabled = !!connectController;\n button(\"connection-settings\").disabled = !companionAvailable || !!connectController || !!queryController || exportBusy;\n button(\"setup-connect\").disabled = !companionAvailable || !!connectController;\n }\n async function call(method, params = {}, signal) {\n return privateConnection ? await client.call(\n \"system.companion.call\",\n { method: `${service}.${method}`, params },\n signal\n ) : await client.services.call(\n `${service}.${method}`,\n params,\n signal\n );\n }\n function unavailable(title, detail) {\n ready = false;\n $(\"connection-state\").textContent = \"Not connected\";\n $(\"connection-state\").classList.remove(\"connected\");\n $(\"destination\").textContent = \"No database connected\";\n $(\"setup\").hidden = false;\n $(\"workspace\").hidden = true;\n $(\"setup-title\").textContent = title;\n $(\"setup-detail\").textContent = detail;\n button(\"connection-settings\").textContent = \"Connect database\";\n status.textContent = detail;\n setControls();\n }\n function clearResult(note = \"Run a query to see results.\", release = true) {\n const old = current;\n current = null;\n if (release && old)\n void call(\"closeResult\", {\n resultId: old.resultId,\n offset: 0,\n pageSize: 1\n }).catch(() => {\n });\n document.querySelector(\"thead\").replaceChildren();\n document.querySelector(\"tbody\").replaceChildren();\n $(\"summary\").textContent = \"Results\";\n $(\"page\").textContent = \"\";\n $(\"warnings\").hidden = true;\n $(\"empty\").hidden = false;\n $(\"empty\").textContent = note;\n previousOffsets = [];\n setControls();\n }\n function render(page) {\n current = page;\n const head = document.querySelector(\"thead\"), body = document.querySelector(\"tbody\");\n head.replaceChildren();\n body.replaceChildren();\n if (page.columns.length) {\n const tr = document.createElement(\"tr\");\n for (const column of page.columns) {\n const th = document.createElement(\"th\");\n th.textContent = column.name;\n th.title = column.databaseType;\n tr.append(th);\n }\n head.append(tr);\n }\n for (const row of page.rows) {\n const tr = document.createElement(\"tr\");\n for (const cell of row) {\n const td = document.createElement(\"td\");\n td.textContent = cell.kind === \"null\" ? \"NULL\" : cell.display;\n td.className = `cell-${cell.kind}`;\n tr.append(td);\n }\n body.append(tr);\n }\n $(\"empty\").hidden = page.rows.length > 0;\n $(\"empty\").textContent = page.columns.length ? \"The query returned no rows.\" : `Statement completed. ${page.affectedRows.toLocaleString()} rows affected.`;\n $(\"summary\").textContent = page.columns.length ? `${page.totalRows.toLocaleString()} rows${page.truncated ? \" \\xB7 limit reached\" : \"\"}` : `${page.affectedRows.toLocaleString()} rows affected`;\n $(\"page\").textContent = page.totalRows ? `${page.offset + 1}\\u2013${page.offset + page.rows.length} of ${page.totalRows.toLocaleString()}` : \"\";\n $(\"warnings\").textContent = page.warnings.join(\" \\xB7 \");\n $(\"warnings\").hidden = !page.warnings.length;\n setControls();\n }\n function quote(name) {\n return provider === \"mysql\" ? `\\`${name.replaceAll(\"`\", \"``\")}\\`` : provider === \"sqlserver\" ? `[${name.replaceAll(\"]\", \"]]\")}]` : `\"${name.replaceAll('\"', '\"\"')}\"`;\n }\n function tableSql(schema, table) {\n const full = provider === \"sqlite\" ? quote(table) : `${quote(schema)}.${quote(table)}`;\n return provider === \"sqlserver\" ? `SELECT TOP (200) * FROM ${full};` : `SELECT * FROM ${full} LIMIT 200;`;\n }\n async function loadSchemas() {\n const op = ++schemaOp, gen = generation, tree = $(\"tree\");\n tree.textContent = \"Loading schemas\\u2026\";\n try {\n const value = await call(\"schemas\");\n if (op !== schemaOp || gen !== generation) return;\n tree.replaceChildren();\n for (const schema of value.schemas) {\n const details = document.createElement(\"details\"), summary = document.createElement(\"summary\");\n summary.textContent = schema;\n details.append(summary);\n details.addEventListener(\"toggle\", () => {\n if (details.open && details.childElementCount === 1)\n void loadTables(details, schema, gen);\n });\n tree.append(details);\n if (value.schemas.length === 1) details.open = true;\n }\n if (!value.schemas.length)\n tree.textContent = \"No schemas visible to this account.\";\n } catch (error) {\n if (op === schemaOp && gen === generation) {\n tree.textContent = \"Could not load schemas. Use Refresh to retry.\";\n report(error);\n }\n }\n }\n async function loadTables(details, schema, gen) {\n const wait = document.createElement(\"p\");\n wait.textContent = \"Loading tables\\u2026\";\n details.append(wait);\n try {\n const value = await call(\"tables\", { schema });\n if (gen !== generation || !details.isConnected) return;\n wait.remove();\n for (const table of value.tables) {\n const item = document.createElement(\"button\");\n item.className = \"table\";\n item.textContent = table.name;\n item.title = `${table.kind} \\xB7 insert a query`;\n item.onclick = () => {\n sql.value = tableSql(schema, table.name);\n sql.focus();\n };\n details.append(item);\n }\n if (!value.tables.length) {\n wait.textContent = \"No tables\";\n details.append(wait);\n }\n } catch (error) {\n if (gen === generation && details.isConnected) {\n wait.textContent = \"Could not load tables. Refresh schemas to retry.\";\n report(error);\n }\n }\n }\n async function execute() {\n if (!ready || queryController || !sql.value.trim() || connectController || exportBusy)\n return;\n const gen = generation, active = new AbortController();\n queryController = active;\n clearResult(\"Running query\\u2026\");\n setControls();\n status.textContent = \"Running query\\u2026\";\n try {\n const page = await call(\n \"query\",\n { sql: sql.value, pageSize },\n active.signal\n );\n if (gen !== generation) return;\n render(page);\n status.textContent = \"Query completed.\";\n } catch (error) {\n if (gen === generation) {\n clearResult(\n active.signal.aborted ? \"Query canceled. Check the database before retrying statements that change data.\" : \"The query failed.\"\n );\n report(error);\n }\n } finally {\n if (queryController === active) queryController = null;\n setControls();\n }\n }\n async function move(offset, previous = false) {\n if (!current || pageBusy || !ready) return;\n const op = ++pageOp, gen = generation, original = current;\n pageBusy = true;\n setControls();\n try {\n const page = await call(\"page\", {\n resultId: original.resultId,\n offset,\n pageSize\n });\n if (op === pageOp && gen === generation && current === original) {\n if (previous) previousOffsets.pop();\n else previousOffsets.push(original.offset);\n render(page);\n }\n } catch (error) {\n if (op === pageOp && gen === generation) report(error);\n } finally {\n if (op === pageOp) pageBusy = false;\n setControls();\n }\n }\n async function cancelExport() {\n const job = exportJob;\n exportJob = null;\n if (job) await call(\"exportCancel\", { jobId: job }).catch(() => {\n });\n }\n async function exportCsv() {\n if (!current || exportBusy) return;\n const gen = generation;\n exportBusy = true;\n setControls();\n status.textContent = \"Choose a CSV destination\\u2026\";\n try {\n const start2 = await call(\"exportBegin\", {\n resultId: current.resultId,\n suggestedName: \"query-results.csv\",\n spreadsheetSafe: input(\"safe\").checked\n });\n if (gen !== generation) {\n await call(\"exportCancel\", { jobId: start2.jobId }).catch(() => {\n });\n return;\n }\n exportJob = start2.jobId;\n setControls();\n for (let polls = 0; polls < 180 && exportJob === start2.jobId; polls++) {\n await new Promise((resolve) => setTimeout(resolve, 350));\n if (gen !== generation) return;\n const job = await call(\"exportStatus\", { jobId: start2.jobId });\n if (gen !== generation) return;\n if (job.state === \"saved\" || job.state === \"canceled\") {\n status.textContent = job.state === \"saved\" ? `Saved ${job.name ?? \"CSV\"}.` : \"CSV save canceled.\";\n exportJob = null;\n return;\n }\n if (job.state === \"failed\")\n throw new Error(job.message ?? \"CSV export failed\");\n if (job.state !== \"choosing\" && job.state !== \"writing\")\n throw new Error(\"Unknown CSV export state\");\n }\n throw new Error(\"CSV save timed out\");\n } catch (error) {\n if (gen === generation) {\n await cancelExport();\n report(error);\n }\n } finally {\n exportBusy = false;\n setControls();\n }\n }\n async function refresh(force = false) {\n if (closed || !client) return;\n const op = ++bindOp;\n try {\n const env = await client.environment.get();\n if (op !== bindOp || closed) return;\n applyAppearance(document.documentElement, env.appearance);\n const systemMethods = await client.services.list();\n if (op !== bindOp || closed) return;\n companionAvailable = systemMethods.some(\n (method) => method.name === \"system.companion.connect\" && method.available && method.granted\n );\n const own = companionAvailable ? await client.call(\"system.companion.status\") : { connected: false, binding: null };\n if (op !== bindOp || closed || connectController) return;\n const methods = own.connected ? await client.call(\n \"system.companion.list\"\n ) : systemMethods;\n if (op !== bindOp || closed) return;\n const routes = companionAvailable ? await client.call(\"system.companion.routes\").catch(() => ({ ssh: false })) : { ssh: false };\n if (op !== bindOp || closed) return;\n sshAvailable = routes.ssh;\n sshHostName = env.host?.name ?? \"connected host\";\n updateRoute();\n const next = JSON.stringify([\n own.connected,\n own.binding,\n own.connected ? null : env.binding,\n own.connected ? null : env.connection,\n methods.filter((m) => m.name.startsWith(service + \".\")).map((m) => [m.name, m.available, m.granted, m.source])\n ]);\n if (!force && signature === next) return;\n generation++;\n schemaOp++;\n pageOp++;\n pageBusy = false;\n ready = false;\n queryController?.abort();\n queryController = null;\n await cancelExport();\n if (op !== bindOp || closed) return;\n privateConnection = own.connected;\n clearResult(\"Run a query to see results.\", false);\n const state = connectionState(env, methods, own.connected);\n if (!state.ready) {\n signature = next;\n unavailable(state.title, state.detail);\n return;\n }\n $(\"connection-state\").textContent = \"Checking database\\u2026\";\n const info = await call(\"capabilities\");\n if (op !== bindOp || closed) return;\n signature = next;\n provider = info.provider;\n ready = true;\n $(\"setup\").hidden = true;\n $(\"workspace\").hidden = false;\n $(\"connection-state\").textContent = \"Connected\";\n $(\"connection-state\").classList.add(\"connected\");\n $(\"destination\").textContent = privateConnection ? destination || `${provider} \\xB7 app connection` : `${provider} \\xB7 ${env.host?.name ?? \"workspace\"}`;\n button(\"connection-settings\").textContent = \"Connection\\u2026\";\n status.textContent = \"Ready. Choose a table or write a query.\";\n setControls();\n await loadSchemas();\n } catch (error) {\n if (op === bindOp && !closed) {\n signature = null;\n unavailable(\"Connection unavailable\", message(error));\n }\n }\n }\n function updateRoute() {\n const remote = input(\"db-route\").value === \"ssh\";\n $(\"route-hint\").textContent = remote ? sshAvailable ? \"Through \" + sshHostName + \". 127.0.0.1 means that remote host.\" : \"Connect an SSH host first. If it was reconnected, accept the new connection.\" : \"Direct from this PC. 127.0.0.1 means this PC.\";\n }\n function selectProvider() {\n const sqlite = input(\"db-provider\").value === \"sqlite\";\n $(\"network-fields\").hidden = sqlite;\n $(\"sqlite-fields\").hidden = !sqlite;\n for (const id of [\n \"db-route\",\n \"db-host\",\n \"db-port\",\n \"db-name\",\n \"db-user\",\n \"db-password\",\n \"db-tls\",\n \"db-trust\"\n ])\n input(id).disabled = sqlite;\n input(\"db-path\").disabled = !sqlite;\n input(\"db-trust\").disabled = sqlite || !input(\"db-tls\").checked;\n for (const id of [\"db-host\", \"db-port\", \"db-name\", \"db-user\"])\n input(id).required = !sqlite;\n input(\"db-path\").required = sqlite;\n input(\"db-port\").value = String(\n {\n sqlserver: 1433,\n postgresql: 5432,\n mysql: 3306,\n mariadb: 3306\n }[input(\"db-provider\").value] ?? 0\n );\n }\n function openConnection() {\n if (!companionAvailable) return;\n $(\"connect-error\").hidden = true;\n if (!input(\"db-host\").value) input(\"db-host\").value = \"127.0.0.1\";\n updateRoute();\n dialog.showModal();\n $(\"db-provider\").focus();\n }\n function closeConnection() {\n connectController?.abort();\n input(\"db-password\").value = \"\";\n dialog.close();\n }\n async function connect() {\n if (connectController || !$(\"connect-form\").reportValidity())\n return;\n const route = input(\"db-provider\").value === \"sqlite\" ? \"direct\" : input(\"db-route\").value;\n if (route === \"ssh\" && !sshAvailable) {\n $(\"connect-error\").textContent = \"Connect an SSH host first and allow host TCP access when reviewing this app.\";\n $(\"connect-error\").hidden = false;\n return;\n }\n const active = new AbortController();\n connectController = active;\n setControls();\n $(\"connection-fields\").disabled = true;\n button(\"connect-submit\").disabled = true;\n button(\"connect-submit\").textContent = \"Connecting\\u2026\";\n $(\"connect-error\").hidden = true;\n const selected = input(\"db-provider\").value;\n const configuration = selected === \"sqlite\" ? { provider: selected, sqlitePath: input(\"db-path\").value.trim() } : {\n provider: selected,\n host: input(\"db-host\").value.trim(),\n port: Number(input(\"db-port\").value),\n database: input(\"db-name\").value.trim(),\n username: input(\"db-user\").value.trim(),\n password: input(\"db-password\").value,\n tls: input(\"db-tls\").checked,\n trustServerCertificate: input(\"db-tls\").checked && input(\"db-trust\").checked\n };\n const target = selected === \"sqlite\" ? input(\"db-path\").value.trim() : `${configuration.host}:${configuration.port} / ${configuration.database}`;\n status.textContent = `Connecting to ${target}${route === \"ssh\" ? ` through ${sshHostName}` : \" from this PC\"}\\u2026`;\n try {\n await client.call(\n \"system.companion.connect\",\n { configuration, route },\n active.signal\n );\n destination = `${$(\"db-provider\").selectedOptions[0].text} \\xB7 ${target} \\xB7 ${route === \"ssh\" ? sshHostName : \"this PC\"}`;\n input(\"db-password\").value = \"\";\n dialog.close();\n } catch (error) {\n if (!active.signal.aborted) {\n $(\"connect-error\").textContent = message(error);\n $(\"connect-error\").hidden = false;\n }\n status.textContent = active.signal.aborted ? \"Connection canceled.\" : `Could not connect to ${target}.`;\n } finally {\n connectController = null;\n $(\"connection-fields\").disabled = false;\n button(\"connect-submit\").disabled = false;\n button(\"connect-submit\").textContent = \"Connect\";\n await refresh(true);\n }\n }\n async function start() {\n client = await connectToShellCanvas();\n client.events.subscribe((batch) => {\n for (const event of batch.events)\n if (event.topic === \"system.environment\")\n applyAppearance(\n document.documentElement,\n event.value?.appearance\n );\n if (batch.events.some(\n (event) => [\"system.environment\", \"system.services\", \"system.companion\"].includes(\n event.topic\n )\n ))\n void refresh();\n }, report);\n window.addEventListener(\n \"pagehide\",\n () => {\n closed = true;\n generation++;\n bindOp++;\n queryController?.abort();\n connectController?.abort();\n client.dispose();\n },\n { once: true }\n );\n await refresh();\n }\n button(\"run\").onclick = () => void execute();\n button(\"cancel\").onclick = () => queryController?.abort();\n button(\"reload\").onclick = () => void loadSchemas();\n button(\"export\").onclick = () => void exportCsv();\n button(\"prev\").onclick = () => {\n const offset = previousOffsets.at(-1);\n if (offset !== void 0) void move(offset, true);\n };\n button(\"next\").onclick = () => {\n if (current) void move(nextOffset(current));\n };\n button(\"retry\").onclick = () => void refresh(true);\n button(\"connection-settings\").onclick = openConnection;\n button(\"setup-connect\").onclick = openConnection;\n button(\"close-dialog\").onclick = closeConnection;\n button(\"connect-cancel\").onclick = closeConnection;\n dialog.addEventListener(\"cancel\", (event) => {\n event.preventDefault();\n closeConnection();\n });\n button(\"connect-submit\").onclick = () => void connect();\n $(\"connect-form\").addEventListener(\"submit\", (event) => event.preventDefault());\n $(\"connect-form\").addEventListener(\"keydown\", (event) => {\n if (event.key === \"Enter\" && !event.isComposing && !event.ctrlKey && !event.metaKey && !event.altKey && event.target instanceof HTMLInputElement && event.target.type !== \"checkbox\") {\n event.preventDefault();\n void connect();\n }\n });\n $(\"db-provider\").addEventListener(\"change\", selectProvider);\n $(\"db-route\").addEventListener(\"change\", updateRoute);\n input(\"db-tls\").addEventListener(\"change\", () => {\n input(\"db-trust\").disabled = !input(\"db-tls\").checked;\n });\n button(\"disconnect\").onclick = () => {\n void (async () => {\n queryController?.abort();\n await cancelExport();\n await client.call(\"system.companion.disconnect\");\n destination = \"\";\n await refresh(true);\n })().catch(report);\n };\n sql.addEventListener(\"keydown\", (event) => {\n if ((event.ctrlKey || event.metaKey) && event.key === \"Enter\") {\n event.preventDefault();\n void execute();\n }\n });\n selectProvider();\n setControls();\n void start().catch(\n (error) => unavailable(\"ShellCanvas connection unavailable\", message(error))\n );\n})();\n","style":"/* SPDX-License-Identifier: MPL-2.0 */\r\n:root {\r\n --sc-base: #19191c;\r\n --sc-surface: #232326;\r\n --sc-raised: #303034;\r\n --sc-inset: #1d1d20;\r\n --sc-text: #e8e8ec;\r\n --sc-muted: #aaaab4;\r\n --sc-accent: #7ab8ff;\r\n --sc-onAccent: #14283e;\r\n --sc-border: #44444b;\r\n --sc-hover: #38383e;\r\n --sc-selection: #293f58;\r\n --sc-danger: #f2a4a4;\r\n --sc-dangerSoft: #482d32;\r\n --sc-warning: #eec778;\r\n --sc-success: #88cda5;\r\n font-family: \"Segoe UI\", system-ui, sans-serif;\r\n font-size: 13px;\r\n color-scheme: dark;\r\n}\r\n* {\r\n box-sizing: border-box;\r\n}\r\n[hidden] {\r\n display: none !important;\r\n}\r\nhtml,\r\nbody {\r\n margin: 0;\r\n height: 100%;\r\n background: var(--sc-surface);\r\n color: var(--sc-text);\r\n font:\r\n 13px/1.5 \"Segoe UI\",\r\n system-ui,\r\n sans-serif;\r\n}\r\nbutton,\r\ninput,\r\nselect,\r\ntextarea {\r\n font: inherit;\r\n}\r\nbutton {\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n gap: 7px;\r\n min-height: 32px;\r\n padding: 6px 12px;\r\n border: 1px solid var(--sc-border);\r\n border-radius: 6px;\r\n background: transparent;\r\n color: var(--sc-text);\r\n cursor: pointer;\r\n white-space: nowrap;\r\n}\r\nbutton:hover:not(:disabled) {\r\n background: var(--sc-hover);\r\n}\r\nbutton:disabled {\r\n opacity: 0.45;\r\n cursor: default;\r\n}\r\nbutton.primary {\r\n background: var(--sc-accent);\r\n border-color: var(--sc-accent);\r\n color: var(--sc-onAccent);\r\n font-weight: 600;\r\n}\r\nbutton.primary:hover:not(:disabled) {\r\n filter: brightness(1.08);\r\n background: var(--sc-accent);\r\n}\r\n:focus-visible {\r\n outline: 2px solid var(--sc-accent);\r\n outline-offset: 2px;\r\n}\r\nmain {\r\n height: 100vh;\r\n display: flex;\r\n flex-direction: column;\r\n overflow: hidden;\r\n}\r\nh1,\r\nh2,\r\np {\r\n margin: 0;\r\n}\r\nh1 {\r\n font-size: 15px;\r\n font-weight: 600;\r\n}\r\nh2 {\r\n font-size: 13px;\r\n font-weight: 600;\r\n}\r\nsvg {\r\n width: 22px;\r\n height: 22px;\r\n flex-shrink: 0;\r\n}\r\n.app-header {\r\n display: flex;\r\n gap: 20px;\r\n align-items: center;\r\n justify-content: space-between;\r\n min-height: 76px;\r\n padding: 14px 20px;\r\n border-bottom: 1px solid var(--sc-border);\r\n}\r\n.app-heading,\r\n.header-actions {\r\n display: flex;\r\n align-items: center;\r\n gap: 12px;\r\n min-width: 0;\r\n}\r\n.app-heading p {\r\n color: var(--sc-muted);\r\n font-size: 12px;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n max-width: 50vw;\r\n}\r\n.app-icon {\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n color: var(--sc-accent);\r\n width: 38px;\r\n height: 38px;\r\n border-radius: 10px;\r\n background: var(--sc-selection);\r\n flex-shrink: 0;\r\n}\r\n.connection-state {\r\n font-size: 12px;\r\n color: var(--sc-muted);\r\n white-space: nowrap;\r\n}\r\n.connection-state::before {\r\n content: \"\";\r\n display: inline-block;\r\n width: 6px;\r\n height: 6px;\r\n margin-right: 7px;\r\n border-radius: 50%;\r\n background: currentColor;\r\n}\r\n.connection-state.connected {\r\n color: var(--sc-success);\r\n}\r\n.setup {\r\n display: grid;\r\n place-items: center;\r\n flex: 1;\r\n overflow: auto;\r\n padding: 28px;\r\n}\r\n.setup-card {\r\n text-align: center;\r\n max-width: 490px;\r\n}\r\n.setup-icon {\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n width: 64px;\r\n height: 64px;\r\n border: 1px solid var(--sc-border);\r\n border-radius: 16px;\r\n color: var(--sc-accent);\r\n background: var(--sc-raised);\r\n margin-bottom: 20px;\r\n}\r\n.setup-icon svg {\r\n width: 32px;\r\n height: 32px;\r\n}\r\n.setup h2 {\r\n font-size: 23px;\r\n font-weight: 600;\r\n margin-bottom: 10px;\r\n}\r\n.setup p {\r\n color: var(--sc-muted);\r\n line-height: 1.7;\r\n}\r\n.provider-list {\r\n display: flex;\r\n flex-wrap: wrap;\r\n gap: 8px;\r\n justify-content: center;\r\n margin: 22px 0;\r\n}\r\n.provider-list span {\r\n font-size: 11px;\r\n border: 1px solid var(--sc-border);\r\n padding: 4px 9px;\r\n border-radius: 5px;\r\n color: var(--sc-muted);\r\n}\r\n.setup button {\r\n margin: 0 3px;\r\n}\r\n.setup .setup-note {\r\n font-size: 12px;\r\n margin: 24px auto 0;\r\n max-width: 360px;\r\n}\r\n.workspace {\r\n display: grid;\r\n grid-template-columns: 210px minmax(0, 1fr);\r\n min-height: 0;\r\n flex: 1;\r\n}\r\naside {\r\n background: var(--sc-inset);\r\n border-right: 1px solid var(--sc-border);\r\n padding: 16px 12px;\r\n overflow: auto;\r\n}\r\n.side-head {\r\n display: flex;\r\n align-items: center;\r\n justify-content: space-between;\r\n gap: 10px;\r\n padding: 0 6px 12px;\r\n}\r\n.side-head h2 {\r\n color: var(--sc-muted);\r\n font-size: 11px;\r\n text-transform: uppercase;\r\n letter-spacing: 0.08em;\r\n}\r\n.icon-button {\r\n padding: 0;\r\n min-height: 28px;\r\n width: 28px;\r\n font-size: 18px;\r\n border-color: transparent;\r\n}\r\n#tree {\r\n color: var(--sc-muted);\r\n font-size: 12px;\r\n}\r\ndetails {\r\n margin: 4px 0;\r\n}\r\nsummary {\r\n padding: 5px 6px;\r\n cursor: pointer;\r\n color: var(--sc-text);\r\n}\r\ndetails p {\r\n padding: 6px 16px;\r\n}\r\n.table {\r\n display: block;\r\n border: 0;\r\n width: 100%;\r\n text-align: left;\r\n min-height: 30px;\r\n padding: 5px 12px 5px 25px;\r\n font-size: 12px;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n}\r\n.work {\r\n padding: 18px;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 10px;\r\n min-width: 0;\r\n min-height: 0;\r\n}\r\n.editor-heading {\r\n display: flex;\r\n align-items: center;\r\n justify-content: space-between;\r\n font-size: 12px;\r\n}\r\n.editor-heading span {\r\n font-size: 11px;\r\n color: var(--sc-muted);\r\n}\r\ntextarea {\r\n width: 100%;\r\n min-height: 90px;\r\n height: 150px;\r\n max-height: 50vh;\r\n flex-shrink: 0;\r\n resize: vertical;\r\n border: 1px solid var(--sc-border);\r\n border-radius: 6px;\r\n background: var(--sc-inset);\r\n color: var(--sc-text);\r\n padding: 12px 14px;\r\n font:\r\n 13px/1.7 Consolas,\r\n \"Cascadia Code\",\r\n monospace;\r\n tab-size: 2;\r\n}\r\n.toolbar,\r\n.result-head,\r\n.result-head > div,\r\n.pager {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n}\r\n.query-note {\r\n margin-left: auto;\r\n color: var(--sc-muted);\r\n font-size: 11px;\r\n}\r\n.result-head {\r\n justify-content: space-between;\r\n margin-top: 10px;\r\n}\r\n.safe {\r\n font-size: 11px;\r\n color: var(--sc-muted);\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 5px;\r\n}\r\ninput[type=\"checkbox\"] {\r\n accent-color: var(--sc-accent);\r\n}\r\n.grid-wrap {\r\n overflow: auto;\r\n min-height: 100px;\r\n flex: 1;\r\n border: 1px solid var(--sc-border);\r\n border-radius: 6px;\r\n background: var(--sc-inset);\r\n}\r\ntable {\r\n border-collapse: separate;\r\n border-spacing: 0;\r\n min-width: 100%;\r\n font:\r\n 12px/1.5 Consolas,\r\n monospace;\r\n}\r\nth,\r\ntd {\r\n padding: 8px 12px;\r\n border-right: 1px solid var(--sc-border);\r\n border-bottom: 1px solid var(--sc-border);\r\n white-space: pre;\r\n max-width: 420px;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n text-align: left;\r\n}\r\nth {\r\n position: sticky;\r\n top: 0;\r\n background: var(--sc-raised);\r\n color: var(--sc-muted);\r\n font-weight: 500;\r\n}\r\ntr:hover td {\r\n background: var(--sc-hover);\r\n}\r\n.cell-null {\r\n color: var(--sc-muted);\r\n font-style: italic;\r\n}\r\n.cell-binary {\r\n color: var(--sc-accent);\r\n}\r\n.cell-unsupported,\r\n#warnings {\r\n color: var(--sc-warning);\r\n}\r\n#warnings {\r\n font-size: 12px;\r\n}\r\n#empty {\r\n padding: 28px;\r\n text-align: center;\r\n color: var(--sc-muted);\r\n font-size: 12px;\r\n}\r\n.pager {\r\n justify-content: flex-end;\r\n font-size: 11px;\r\n color: var(--sc-muted);\r\n}\r\n.pager #page {\r\n margin-right: auto;\r\n}\r\n.pager button {\r\n font-size: 11px;\r\n min-height: 28px;\r\n padding: 4px 10px;\r\n}\r\nfooter {\r\n display: flex;\r\n align-items: center;\r\n justify-content: space-between;\r\n gap: 12px;\r\n min-height: 32px;\r\n padding: 6px 16px;\r\n border-top: 1px solid var(--sc-border);\r\n color: var(--sc-muted);\r\n font-size: 11px;\r\n}\r\nfooter > :first-child {\r\n overflow-wrap: anywhere;\r\n}\r\nfooter > :last-child {\r\n white-space: nowrap;\r\n}\r\ndialog {\r\n color: var(--sc-text);\r\n background: var(--sc-surface);\r\n border: 1px solid var(--sc-border);\r\n border-radius: 14px;\r\n padding: 26px;\r\n width: min(520px, calc(100vw - 32px));\r\n max-height: calc(100vh - 32px);\r\n overflow: auto;\r\n box-shadow: 0 20px 70px #0006;\r\n}\r\ndialog::backdrop {\r\n background: #0007;\r\n}\r\n.dialog-heading {\r\n display: flex;\r\n justify-content: space-between;\r\n align-items: center;\r\n margin-bottom: 14px;\r\n}\r\ndialog h2 {\r\n font-size: 23px;\r\n margin-bottom: 8px;\r\n}\r\ndialog p {\r\n color: var(--sc-muted);\r\n font-size: 12px;\r\n line-height: 1.6;\r\n}\r\nfieldset {\r\n border: 0;\r\n margin: 18px 0 0;\r\n padding: 0;\r\n min-width: 0;\r\n}\r\nfieldset label {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 6px;\r\n font-size: 12px;\r\n margin-bottom: 12px;\r\n}\r\nfieldset input:not([type=\"checkbox\"]),\r\nselect {\r\n width: 100%;\r\n min-width: 0;\r\n padding: 9px 10px;\r\n border: 1px solid var(--sc-border);\r\n border-radius: 6px;\r\n background: var(--sc-raised);\r\n color: var(--sc-text);\r\n}\r\n.field-row {\r\n display: flex;\r\n gap: 12px;\r\n}\r\n.field-row label {\r\n flex: 1;\r\n min-width: 0;\r\n}\r\n.field-row .port-field {\r\n flex: 0 0 100px;\r\n}\r\nfieldset .checkbox {\r\n flex-direction: row;\r\n align-items: center;\r\n gap: 7px;\r\n margin: 10px 0;\r\n}\r\n.field-hint {\r\n margin: 10px 0;\r\n}\r\n.dialog-actions {\r\n display: flex;\r\n gap: 8px;\r\n align-items: center;\r\n margin-top: 20px;\r\n}\r\n.dialog-actions span {\r\n margin-right: auto;\r\n font-size: 11px;\r\n color: var(--sc-muted);\r\n}\r\n.error {\r\n margin-top: 14px;\r\n padding: 10px 12px;\r\n border-radius: 6px;\r\n background: var(--sc-dangerSoft);\r\n color: var(--sc-danger);\r\n}\r\n@media (max-width: 700px) {\r\n .app-header {\r\n padding: 12px;\r\n gap: 10px;\r\n }\r\n .connection-state {\r\n display: none;\r\n }\r\n .workspace {\r\n grid-template-columns: 155px minmax(0, 1fr);\r\n }\r\n .work {\r\n padding: 12px;\r\n }\r\n .query-note {\r\n display: none;\r\n }\r\n .result-head {\r\n align-items: flex-start;\r\n gap: 8px;\r\n flex-wrap: wrap;\r\n }\r\n .safe {\r\n font-size: 10px;\r\n }\r\n footer > :last-child {\r\n display: none;\r\n }\r\n}\r\n@media (max-width: 480px) {\r\n .workspace {\r\n grid-template-columns: 1fr;\r\n }\r\n aside {\r\n max-height: 145px;\r\n border-right: 0;\r\n border-bottom: 1px solid var(--sc-border);\r\n }\r\n .app-heading p {\r\n max-width: 40vw;\r\n }\r\n .app-header .app-icon {\r\n display: none;\r\n }\r\n .field-row {\r\n flex-wrap: wrap;\r\n }\r\n}\r\n"} \ No newline at end of file diff --git a/app/package-lock.json b/app/package-lock.json index b4f4973..c5b0ce9 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "dev.shellcanvas.database.app", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dev.shellcanvas.database.app", - "version": "0.1.0", + "version": "0.1.1", "devDependencies": { "@techartdev/shellcanvas-app-sdk": "file:../vendor/techartdev-shellcanvas-app-sdk-0.1.11.tgz", "typescript": "5.8.3" diff --git a/app/package.json b/app/package.json index bdd3b39..648a30b 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "dev.shellcanvas.database.app", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "scripts": { diff --git a/app/scripts/repository.mjs b/app/scripts/repository.mjs index 286921f..c61d01e 100644 --- a/app/scripts/repository.mjs +++ b/app/scripts/repository.mjs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MPL-2.0 import { createHash, randomUUID } from "node:crypto"; -import { readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { readFile, readdir, rename, unlink, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parseAppPackage } from "../node_modules/@techartdev/shellcanvas-app-sdk/dist/package.js"; @@ -8,7 +8,6 @@ import { parseAppRepository } from "../node_modules/@techartdev/shellcanvas-app- const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const packagePath = "app/dist/app.shellcanvas.json"; -const adapterPath = "dist/adapter-windows-x86_64/adapter.json"; const description = "Query SQL Server, PostgreSQL, MySQL/MariaDB and SQLite from ShellCanvas."; @@ -16,19 +15,32 @@ const packageBytes = await readFile(join(root, packagePath)); const app = parseAppPackage( new TextDecoder("utf-8", { fatal: true }).decode(packageBytes), ); -const adapterBytes = await readFile(join(root, adapterPath)); -let adapter; -try { - adapter = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(adapterBytes)); -} catch { - throw new Error("The packaged native adapter manifest is not valid UTF-8 JSON."); +const platforms = ["windows-x86_64", "linux-x86_64", "macos-x86_64", "macos-aarch64"]; +const packages = []; +for (const platform of platforms) { + const path = `dist/adapter-${platform}/adapter.json`; + const bytes = await readFile(join(root, path)); + let adapter; + try { + adapter = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + } catch { + throw new Error(`${platform} adapter manifest is not valid UTF-8 JSON.`); + } + if ( + adapter?.id !== "dev.shellcanvas.database" || + adapter?.version !== app.version || + adapter?.platform !== platform + ) + throw new Error(`${platform} adapter identity, version, or platform changed.`); + const names = await readdir(join(root, `dist/adapter-${platform}/bin`)); + if (names.length !== 1 || adapter.entrypoint !== `bin/${names[0]}`) + throw new Error(`${platform} adapter entrypoint is missing or unexpected.`); + const executable = await readFile(join(root, `dist/adapter-${platform}`, adapter.entrypoint)); + const entry = adapter.files.find((file) => file.path === adapter.entrypoint); + if (entry?.size !== executable.length || entry.sha256 !== createHash("sha256").update(executable).digest("hex")) + throw new Error(`${platform} adapter executable does not match its manifest.`); + packages.push({ platform, path, sha256: createHash("sha256").update(bytes).digest("hex") }); } -if ( - adapter?.id !== "dev.shellcanvas.database" || - adapter?.version !== "0.1.0" || - adapter?.platform !== "windows-x86_64" -) - throw new Error("The packaged native adapter identity, version, or platform changed."); const descriptorPath = join(root, "shellcanvas.repo.json"); const descriptor = { @@ -45,15 +57,9 @@ const descriptor = { }, })), nativeAdapter: { - id: adapter.id, - version: adapter.version, - packages: [ - { - platform: adapter.platform, - path: adapterPath, - sha256: createHash("sha256").update(adapterBytes).digest("hex"), - }, - ], + id: app.id, + version: app.version, + packages, }, }; const temporary = join(root, `.repository-native-${randomUUID()}.tmp`); diff --git a/app/scripts/verify-repository.mjs b/app/scripts/verify-repository.mjs index 144e7a5..146a84e 100644 --- a/app/scripts/verify-repository.mjs +++ b/app/scripts/verify-repository.mjs @@ -32,8 +32,11 @@ if (app.id !== descriptor.id || app.version !== descriptor.version) throw new Error("App package identity/version does not match the descriptor."); const dependency = descriptor.nativeAdapter; -if (!dependency || dependency.packages.length !== 1) - throw new Error("Expected one pinned native adapter package."); +const expected = ["windows-x86_64", "linux-x86_64", "macos-x86_64", "macos-aarch64"]; +if (!dependency || dependency.packages.length !== expected.length) + throw new Error("Expected four pinned native adapter packages."); +if (dependency.packages.some((entry, index) => entry.platform !== expected[index])) + throw new Error("Native adapter platform set or order changed."); for (const entry of dependency.packages) { const manifestBytes = readTree(entry.path); expectHash(manifestBytes, entry.sha256, `${entry.platform} adapter manifest`); @@ -52,4 +55,4 @@ for (const entry of dependency.packages) { expectHash(bytes, file.sha256, file.path); } } -console.log(`Verified committed app, ${dependency.packages.length} adapter manifest, and all native assets.`); +console.log(`Verified committed app, ${dependency.packages.length} adapter manifests, and all native assets.`); diff --git a/app/shellcanvas.json b/app/shellcanvas.json index d0eb895..b749023 100644 --- a/app/shellcanvas.json +++ b/app/shellcanvas.json @@ -3,7 +3,7 @@ "format": 1, "kind": "app", "id": "dev.shellcanvas.database", - "version": "0.1.0", + "version": "0.1.1", "title": "ShellCanvas Database", "description": "Browse database schemas, run SQL, inspect typed results and export CSV.", "permissions": [ diff --git a/dist/adapter-linux-x86_64/adapter.json b/dist/adapter-linux-x86_64/adapter.json new file mode 100644 index 0000000..e545488 --- /dev/null +++ b/dist/adapter-linux-x86_64/adapter.json @@ -0,0 +1,90 @@ +{ + "schemaVersion": 1, + "id": "dev.shellcanvas.database", + "name": "ShellCanvas Database", + "version": "0.1.1", + "description": "Database connections through the connected SSH host, or directly from this PC.", + "platform": "linux-x86_64", + "entrypoint": "bin/shellcanvas-device", + "arguments": [], + "files": [ + { + "path": "bin/shellcanvas-device", + "size": 12728336, + "sha256": "ecdbd4031bfbf531e91cc86d065022c14f0512bf3354092f6c29fd918dec98ca", + "executable": true + } + ], + "configuration": [ + { + "id": "provider", + "label": "Provider (sqlite, postgresql, mysql, mariadb, sqlserver)", + "kind": "text", + "required": true, + "default": "sqlite" + }, + { + "id": "host", + "label": "Database host", + "kind": "text", + "required": false, + "default": "localhost" + }, + { + "id": "port", + "label": "Port (leave 0 for provider default)", + "kind": "number", + "required": false, + "default": 0 + }, + { + "id": "database", + "label": "Database name", + "kind": "text", + "required": false, + "default": "" + }, + { + "id": "username", + "label": "Username", + "kind": "text", + "required": false, + "default": "" + }, + { + "id": "password", + "label": "Password", + "kind": "password", + "required": false, + "default": null + }, + { + "id": "tls", + "label": "Use TLS", + "kind": "boolean", + "required": false, + "default": true + }, + { + "id": "trustServerCertificate", + "label": "Trust server certificate without identity verification", + "kind": "boolean", + "required": false, + "default": false + }, + { + "id": "sqlitePath", + "label": "SQLite path on the adapter machine", + "kind": "text", + "required": false, + "default": "" + }, + { + "id": "tcpProxyPort", + "label": "Managed SSH transport port (leave 0)", + "kind": "number", + "required": false, + "default": 0 + } + ] +} \ No newline at end of file diff --git a/dist/adapter-linux-x86_64/bin/shellcanvas-device b/dist/adapter-linux-x86_64/bin/shellcanvas-device new file mode 100755 index 0000000..5b0a4ca Binary files /dev/null and b/dist/adapter-linux-x86_64/bin/shellcanvas-device differ diff --git a/dist/adapter-macos-aarch64/adapter.json b/dist/adapter-macos-aarch64/adapter.json new file mode 100644 index 0000000..c3d0d66 --- /dev/null +++ b/dist/adapter-macos-aarch64/adapter.json @@ -0,0 +1,90 @@ +{ + "schemaVersion": 1, + "id": "dev.shellcanvas.database", + "name": "ShellCanvas Database", + "version": "0.1.1", + "description": "Database connections through the connected SSH host, or directly from this PC.", + "platform": "macos-aarch64", + "entrypoint": "bin/shellcanvas-device", + "arguments": [], + "files": [ + { + "path": "bin/shellcanvas-device", + "size": 10454160, + "sha256": "bfccbb38dcef933ea16b314565c4c8e81fa38b5546fa3a79f864f1840186434b", + "executable": true + } + ], + "configuration": [ + { + "id": "provider", + "label": "Provider (sqlite, postgresql, mysql, mariadb, sqlserver)", + "kind": "text", + "required": true, + "default": "sqlite" + }, + { + "id": "host", + "label": "Database host", + "kind": "text", + "required": false, + "default": "localhost" + }, + { + "id": "port", + "label": "Port (leave 0 for provider default)", + "kind": "number", + "required": false, + "default": 0 + }, + { + "id": "database", + "label": "Database name", + "kind": "text", + "required": false, + "default": "" + }, + { + "id": "username", + "label": "Username", + "kind": "text", + "required": false, + "default": "" + }, + { + "id": "password", + "label": "Password", + "kind": "password", + "required": false, + "default": null + }, + { + "id": "tls", + "label": "Use TLS", + "kind": "boolean", + "required": false, + "default": true + }, + { + "id": "trustServerCertificate", + "label": "Trust server certificate without identity verification", + "kind": "boolean", + "required": false, + "default": false + }, + { + "id": "sqlitePath", + "label": "SQLite path on the adapter machine", + "kind": "text", + "required": false, + "default": "" + }, + { + "id": "tcpProxyPort", + "label": "Managed SSH transport port (leave 0)", + "kind": "number", + "required": false, + "default": 0 + } + ] +} \ No newline at end of file diff --git a/dist/adapter-macos-aarch64/bin/shellcanvas-device b/dist/adapter-macos-aarch64/bin/shellcanvas-device new file mode 100755 index 0000000..82d71ff Binary files /dev/null and b/dist/adapter-macos-aarch64/bin/shellcanvas-device differ diff --git a/dist/adapter-macos-x86_64/adapter.json b/dist/adapter-macos-x86_64/adapter.json new file mode 100644 index 0000000..b4bb5be --- /dev/null +++ b/dist/adapter-macos-x86_64/adapter.json @@ -0,0 +1,90 @@ +{ + "schemaVersion": 1, + "id": "dev.shellcanvas.database", + "name": "ShellCanvas Database", + "version": "0.1.1", + "description": "Database connections through the connected SSH host, or directly from this PC.", + "platform": "macos-x86_64", + "entrypoint": "bin/shellcanvas-device", + "arguments": [], + "files": [ + { + "path": "bin/shellcanvas-device", + "size": 10773704, + "sha256": "62d83d83f3d5f46d23a706be7453a5335bbf54f7fddf9b20cd7ea853d0151d50", + "executable": true + } + ], + "configuration": [ + { + "id": "provider", + "label": "Provider (sqlite, postgresql, mysql, mariadb, sqlserver)", + "kind": "text", + "required": true, + "default": "sqlite" + }, + { + "id": "host", + "label": "Database host", + "kind": "text", + "required": false, + "default": "localhost" + }, + { + "id": "port", + "label": "Port (leave 0 for provider default)", + "kind": "number", + "required": false, + "default": 0 + }, + { + "id": "database", + "label": "Database name", + "kind": "text", + "required": false, + "default": "" + }, + { + "id": "username", + "label": "Username", + "kind": "text", + "required": false, + "default": "" + }, + { + "id": "password", + "label": "Password", + "kind": "password", + "required": false, + "default": null + }, + { + "id": "tls", + "label": "Use TLS", + "kind": "boolean", + "required": false, + "default": true + }, + { + "id": "trustServerCertificate", + "label": "Trust server certificate without identity verification", + "kind": "boolean", + "required": false, + "default": false + }, + { + "id": "sqlitePath", + "label": "SQLite path on the adapter machine", + "kind": "text", + "required": false, + "default": "" + }, + { + "id": "tcpProxyPort", + "label": "Managed SSH transport port (leave 0)", + "kind": "number", + "required": false, + "default": 0 + } + ] +} \ No newline at end of file diff --git a/dist/adapter-macos-x86_64/bin/shellcanvas-device b/dist/adapter-macos-x86_64/bin/shellcanvas-device new file mode 100755 index 0000000..aba09ae Binary files /dev/null and b/dist/adapter-macos-x86_64/bin/shellcanvas-device differ diff --git a/dist/adapter-windows-x86_64/adapter.json b/dist/adapter-windows-x86_64/adapter.json index 1ddc256..420bcde 100644 --- a/dist/adapter-windows-x86_64/adapter.json +++ b/dist/adapter-windows-x86_64/adapter.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "dev.shellcanvas.database", "name": "ShellCanvas Database", - "version": "0.1.0", + "version": "0.1.1", "description": "Database connections through the connected SSH host, or directly from this PC.", "platform": "windows-x86_64", "entrypoint": "bin/shellcanvas-device.exe", diff --git a/shellcanvas.repo.json b/shellcanvas.repo.json index fc7c21a..4360aa2 100644 --- a/shellcanvas.repo.json +++ b/shellcanvas.repo.json @@ -2,21 +2,36 @@ "format": 1, "kind": "app-repository", "id": "dev.shellcanvas.database", - "version": "0.1.0", + "version": "0.1.1", "title": "ShellCanvas Database", "description": "Query SQL Server, PostgreSQL, MySQL/MariaDB and SQLite from ShellCanvas.", "package": { "path": "app/dist/app.shellcanvas.json", - "sha256": "205d6c4717defa9713cee321af318b3092fceb88264ffce67aca2a16af6a3bc1" + "sha256": "465c74b45a479cc82ecd2a741cac0ab604c69ee0692c66c2febe1e9cde618189" }, "nativeAdapter": { "id": "dev.shellcanvas.database", - "version": "0.1.0", + "version": "0.1.1", "packages": [ { "platform": "windows-x86_64", "path": "dist/adapter-windows-x86_64/adapter.json", - "sha256": "1de82c8002ba973dc7ea39c4c687e37b66a58adacb9e879b1abce87ee982cc1c" + "sha256": "5a396816484cd6344b11c589eac19a6cc5c78780ba04533844eb452c65ff8813" + }, + { + "platform": "linux-x86_64", + "path": "dist/adapter-linux-x86_64/adapter.json", + "sha256": "ea17f8b67da947072bbec0e28b57d39961865192d37cbfb580852b0f2da9a75d" + }, + { + "platform": "macos-x86_64", + "path": "dist/adapter-macos-x86_64/adapter.json", + "sha256": "7c7886446f49e2b136b7e85d618ef177c52858296f32fac8975e6c78721fa2c8" + }, + { + "platform": "macos-aarch64", + "path": "dist/adapter-macos-aarch64/adapter.json", + "sha256": "07b92bd4b0681ece3acd4db73f74e1e5638902324ae5e359106b2138cd861339" } ] }