Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions apps/geolibre-desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,20 +397,32 @@ fn read_local_file(path: String) -> Result<tauri::ipc::Response, String> {
.map_err(|error| format!("Could not read local file: {error}"))
}

/// Add one user-selected GeoTIFF to the asset-protocol scope. The filesystem
/// and asset scopes are separate in Tauri; dialogs and native drops grant the
/// former, but maplibre-gl-raster fetches through the latter for range reads.
/// Add one GeoTIFF to the asset-protocol scope. The filesystem and asset scopes
/// are separate in Tauri; dialogs and native drops grant the former, but
/// maplibre-gl-raster fetches through the latter for range reads. A GeoTIFF
/// referenced by an imported QGIS project is also accepted when that project
/// file itself was explicitly selected by the user.
#[tauri::command]
fn allow_raster_asset(app: tauri::AppHandle, path: String) -> Result<(), String> {
fn allow_raster_asset(
app: tauri::AppHandle,
path: String,
qgis_project_path: Option<String>,
) -> Result<(), String> {
let lower = path.to_ascii_lowercase();
if !is_safe_absolute_path(&path) || !(lower.ends_with(".tif") || lower.ends_with(".tiff")) {
return Err(format!(
"Refusing to expose \"{path}\": not an absolute GeoTIFF path"
));
}
if !app.fs_scope().is_allowed(&path) {
let selected_qgis_project = qgis_project_path.is_some_and(|project_path| {
let lower = project_path.to_ascii_lowercase();
is_safe_absolute_path(&project_path)
&& (lower.ends_with(".qgs") || lower.ends_with(".qgz"))
&& app.fs_scope().is_allowed(&project_path)
});
Comment thread
giswqs marked this conversation as resolved.
Comment thread
giswqs marked this conversation as resolved.
if !app.fs_scope().is_allowed(&path) && !selected_qgis_project {
Comment thread
giswqs marked this conversation as resolved.
return Err(format!(
"Refusing to expose \"{path}\": the file was not selected or dropped by the user"
"Refusing to expose \"{path}\": neither the file nor its QGIS project was selected by the user"
));
}
Comment thread
giswqs marked this conversation as resolved.
app.asset_protocol_scope()
Expand Down
1 change: 1 addition & 0 deletions apps/geolibre-desktop/src/components/layout/TopToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1424,6 +1424,7 @@ export function TopToolbar({
onOpenFromFile={() => void projectFiles.handleOpenFromFile()}
onOpenFromUrl={() => projectFiles.setProjectUrlDialogOpen(true)}
onOpenGallery={() => setGalleryDialogOpen(true)}
onImportQgisProject={() => void projectFiles.handleImportQgisProject()}
onOpenRecent={(path) => {
void projectFiles.handleOpenRecent(path).then((error) => {
if (error) projectFiles.setActionError(error);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,38 @@ export function ProjectFileDialogs({ projectFiles }: ProjectFileDialogsProps) {
</div>
</DialogContent>
</Dialog>
<Dialog
open={projectFiles.qgisImportWarnings !== null}
onOpenChange={(open: boolean) => {
if (!open) projectFiles.setQgisImportWarnings(null);
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t("toolbar.item.qgisImportComplete")}</DialogTitle>
<DialogDescription>
{t("toolbar.item.qgisImportWarnings", {
count: projectFiles.qgisImportWarnings?.length ?? 0,
})}
</DialogDescription>
</DialogHeader>
<ul className="max-h-64 space-y-2 overflow-y-auto text-sm">
{projectFiles.qgisImportWarnings?.map((warning, index) => (
<li key={`${warning.layerName}-${index}`}>
<strong>{warning.layerName}:</strong>{" "}
{t(`toolbar.item.qgisImportReason.${warning.reason}`, {
provider: warning.provider || t("toolbar.item.qgisUnknownProvider"),
})}
</li>
))}
</ul>
<div className="flex justify-end">
<Button onClick={() => projectFiles.setQgisImportWarnings(null)}>
{t("common.ok")}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog
open={projectFiles.saveNamePrompt !== null}
onOpenChange={(open: boolean) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ import {
Bookmark,
Copy,
FileCode2,
FileInput,
FilePen,
FilePlus2,
FileText,
Folder,
FolderOpen,
HardDriveDownload,
History,
Import,
LayoutGrid,
Link2,
Printer,
Expand All @@ -43,6 +45,7 @@ interface ProjectMenuProps {
onOpenFromFile: () => void;
onOpenFromUrl: () => void;
onOpenGallery: () => void;
onImportQgisProject: () => void;
onOpenRecent: (path: string) => void;
onSave: () => void;
onSaveAs: () => void;
Expand All @@ -63,6 +66,7 @@ export function ProjectMenu({
onOpenFromFile,
onOpenFromUrl,
onOpenGallery,
onImportQgisProject,
onOpenRecent,
onSave,
onSaveAs,
Expand Down Expand Up @@ -199,6 +203,20 @@ export function ProjectMenu({
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{show("project.import") && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Import className="h-3.5 w-3.5" />
{t("toolbar.menu.import")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={onImportQgisProject}>
<FileInput className="me-2 h-3.5 w-3.5" />
{t("toolbar.item.importQgisProjectEllipsis")}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{showSaveGroup && <DropdownMenuSeparator />}
{show("project.save") && (
<DropdownMenuItem onSelect={onSave}>
Expand Down
130 changes: 128 additions & 2 deletions apps/geolibre-desktop/src/hooks/useProjectFileActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,20 @@ import {
useAppStore,
type GeoLibreLayer,
} from "@geolibre/core";
import { materializeEmbeddableVectorLayers } from "@geolibre/plugins";
import { addRasterToMap, materializeEmbeddableVectorLayers } from "@geolibre/plugins";
import type { FeatureCollection } from "geojson";
import { type FormEvent, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { getPluginManager } from "./usePlugins";
import { createAppAPI, getPluginManager } from "./usePlugins";
import { pluginManifestUrlsForIds } from "../lib/external-plugins";
import {
browserSaveFallsBackToDownload,
isAbsoluteLocalPath,
isHttpUrl,
isTauri,
loadDroppedRasterPaths,
openProjectFile,
openQgisProjectFile,
openRecentProjectFile,
RecentProjectGoneError,
saveProjectFile,
Expand All @@ -33,6 +35,11 @@ import { resolveShareBaseUrl } from "../lib/share-geolibre";
import { shareAuthorizedFetch } from "../lib/share-gallery";
import { normalizeProjectUrl } from "../lib/urls";
import { resolveProjectXyzLayers } from "../lib/xyz-url";
import {
importQgisProject,
materializeQgisRemoteLayers,
type QgisProjectImportWarning,
} from "../lib/qgis-project-import";
import type { MapControllerRef } from "../components/layout/toolbar/constants";

/** A pending "strip env vars before saving?" prompt. */
Expand Down Expand Up @@ -99,6 +106,33 @@ function isReloadableLocalFileLayer(layer: GeoLibreLayer): boolean {
);
}

/**
* Let React commit a newly loaded project before a plugin attaches native map
* sources. A project load can replace the MapLibre style (and always schedules
* a layer sync); adding a raster in the same tick can therefore attach it to
* the outgoing style. Its store entry survives, but the native raster source
* is removed by the pending style/layer update.
*/
function importedProjectMapReady(
mapControllerRef: MapControllerRef,
basemapWillChange: boolean,
): Promise<void> {
const map = mapControllerRef.current?.getMap();
const styleReady =
map && basemapWillChange
? new Promise<void>((resolve) => map.once("style.load", () => resolve()))
: Promise.resolve();

return (async () => {
// Let the project store update commit and its MapCanvas effects run first.
// Register the style listener above (before loadProject) so a fast inline
// style cannot finish between the store update and this wait.
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
await styleReady;
})();
}

/**
* Bundles every project file action (open from file/URL/recent, save, save as)
* along with the related dialog state (Open-from-URL, env-var strip prompt, and
Expand All @@ -116,6 +150,9 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
const markSaved = useAppStore((s) => s.markSaved);

const [actionError, setActionError] = useState<string | null>(null);
const [qgisImportWarnings, setQgisImportWarnings] = useState<QgisProjectImportWarning[] | null>(
null,
);
const [projectUrlDialogOpen, setProjectUrlDialogOpen] = useState(false);
const [projectUrl, setProjectUrl] = useState("");
const [projectUrlError, setProjectUrlError] = useState<string | null>(null);
Expand Down Expand Up @@ -152,6 +189,92 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
}
};

const handleImportQgisProject = async () => {
const result = await openQgisProjectFile();
if (!result) return;
try {
const imported = await materializeQgisRemoteLayers(
importQgisProject(result.data, result.path),
);
if (!isTauri()) {
const unavailableLayerIds = new Set<string>();
for (const layer of imported.project.layers) {
if (layer.sourcePath && !isHttpUrl(layer.sourcePath)) {
unavailableLayerIds.add(layer.id);
imported.warnings.push({
layerName: layer.name,
reason: "browser-local-file",
});
}
Comment thread
giswqs marked this conversation as resolved.
}
imported.project.layers = imported.project.layers.filter(
(layer) => !unavailableLayerIds.has(layer.id),
);
const usedGroupIds = new Set(
imported.project.layers.flatMap((layer) => (layer.groupId ? [layer.groupId] : [])),
);
imported.project.layerGroups = imported.project.layerGroups?.filter((group) =>
usedGroupIds.has(group.id),
);
for (const raster of imported.rasters) {
imported.warnings.push({
layerName: raster.name,
reason: "browser-local-raster",
});
}
}
Comment thread
giswqs marked this conversation as resolved.
const mapReady = importedProjectMapReady(
mapControllerRef,
useAppStore.getState().basemapStyleUrl !== imported.project.basemapStyleUrl,
);
loadProject(imported.project, null);
if (isTauri()) {
await mapReady;
const app = createAppAPI(mapControllerRef);
for (const raster of imported.rasters) {
try {
const [loaded] = await loadDroppedRasterPaths([raster.sourcePath], {
qgisProjectPath: result.path,
});
if (!loaded) throw new Error("Unsupported raster path");
const rasterLayerId = await addRasterToMap(app, loaded.source, {
name: raster.name,
localPath: raster.sourcePath,
// The Tauri/WebKitGTK WASM backend can stall when its first
// source is created immediately after a project style load.
// GPU renders this local COG directly and preserves the imported
// QGIS ramp, so use the verified backend for project imports.
defaults: { engine: "maplibre-gl-raster" },
state: {
...raster.state,
visible: raster.visible,
opacity: raster.opacity,
},
beforeId: raster.beforeId,
zoomTo: false,
});
if (raster.groupId) {
useAppStore.getState().moveLayerToGroup(rasterLayerId, raster.groupId);
}
} catch (error) {
console.error(`Failed to import QGIS raster "${raster.name}"`, error);
imported.warnings.push({
layerName: raster.name,
reason: "format",
});
}
}
}
Comment thread
giswqs marked this conversation as resolved.
useAppStore.setState({ isDirty: true });
setQgisImportWarnings(imported.warnings.length > 0 ? imported.warnings : null);
} catch (error) {
console.error("Failed to import QGIS project", error);
setActionError(
error instanceof Error ? error.message : t("toolbar.error.couldNotImportQgisProject"),
);
}
};

const handleOpenFromUrl = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const normalizedUrl = normalizeProjectUrl(projectUrl);
Expand Down Expand Up @@ -703,6 +826,8 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
return {
actionError,
setActionError,
qgisImportWarnings,
setQgisImportWarnings,
projectUrlDialogOpen,
setProjectUrlDialogOpen,
handleProjectUrlDialogOpenChange,
Expand All @@ -725,6 +850,7 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
submitSaveNamePrompt,
cancelSaveNamePrompt,
handleOpenFromFile,
handleImportQgisProject,
handleOpenFromUrl,
openProjectFromShareUrl,
handleOpenRecent,
Expand Down
17 changes: 17 additions & 0 deletions apps/geolibre-desktop/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1825,6 +1825,7 @@
"toolbar": {
"menu": {
"project": "Project",
"import": "Import",
"edit": "Edit",
"view": "View",
"addData": "Add Data",
Expand Down Expand Up @@ -2396,6 +2397,21 @@
"webServices": "Web Services",
"commandPalette": "Command Palette",
"openProjectFromUrl": "Open project from URL",
"importQgisProjectEllipsis": "Import QGIS Project…",
"qgisImportComplete": "QGIS project imported",
"qgisImportWarnings_one": "The project was imported, but {{count}} layer could not be loaded.",
"qgisImportWarnings_other": "The project was imported, but {{count}} layers could not be loaded.",
"qgisUnknownProvider": "unknown",
"qgisImportReason": {
"non-vector": "Only file-based vector layers are supported in this first version.",
"provider": "The {{provider}} data provider is not supported.",
"missing-source": "The layer has no readable data source.",
"format": "The layer's file format is not supported.",
"remote-file": "Remote QGIS file sources are not supported yet.",
"network-path": "Network share paths are not loaded for security reasons.",
"browser-local-file": "The local file is referenced, but browsers cannot reopen QGIS data paths. Use GeoLibre Desktop to load it.",
"browser-local-raster": "The raster is referenced by a local path. Use GeoLibre Desktop to load it."
},
"openProjectFromUrlDesc": "Load a `.geolibre.json` project and add it to recent projects.",
"projectUrl": "Project URL",
"opening": "Opening...",
Expand Down Expand Up @@ -2505,6 +2521,7 @@
},
"error": {
"couldNotOpenProject": "Could not open project.",
"couldNotImportQgisProject": "Could not import QGIS project.",
"invalidProjectUrl": "Enter a valid HTTP or HTTPS project URL.",
"couldNotOpenProjectUrl": "Could not open project URL.",
"couldNotOpenRecentProject": "Could not open the recent project.",
Expand Down
Loading
Loading