diff --git a/Taskfile.yml b/Taskfile.yml index 8294748..8bcf8a3 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -22,7 +22,7 @@ tasks: - echo "Building frontend..." - cd {{.FRONTEND_DIR}} && ./build.sh - echo "Building application..." - - wails build -clean + - wails build -clean -tags webkit2_41 - echo "✓ Build complete! Binary at {{.BUILD_DIR}}/{{.BINARY_NAME}}" build-static: diff --git a/app.go b/app.go index b907abb..5265041 100644 --- a/app.go +++ b/app.go @@ -2,13 +2,19 @@ package main import ( "context" + "os" + "os/exec" + "path/filepath" + "strings" + "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/runtime" ) // App exposes system dialogs to the frontend via Wails bindings. type App struct { - ctx context.Context + ctx context.Context + initialFiles []string } // NewApp creates a new App instance. @@ -45,3 +51,88 @@ func (a *App) ShowMessageDialog(title, message string) error { }) return err } + +// filterPDFPaths returns only paths whose extension is .pdf (case-insensitive). +func filterPDFPaths(paths []string) []string { + out := make([]string, 0, len(paths)) + for _, p := range paths { + if strings.EqualFold(filepath.Ext(p), ".pdf") { + out = append(out, p) + } + } + return out +} + +// onFileDrop is the Wails drag-drop callback. Filters non-PDFs and forwards +// the path list to the frontend via the "open-files" event. Wails on Linux +// can fire this twice per drop (once with paths, once empty as WebKit's +// internal handling re-triggers the GTK signal); skip the empty fires so the +// frontend doesn't show a spurious "No PDF files in drop" toast right after +// a successful tab open. +func (a *App) onFileDrop(_, _ int, paths []string) { + if len(paths) == 0 { + return + } + pdfs := filterPDFPaths(paths) + runtime.EventsEmit(a.ctx, "open-files", pdfs) +} + +// resolveSecondInstancePaths takes args from a second-instance launch (which +// may be relative to that process's working directory) and returns absolute +// paths to PDFs only. The .pdf filter ensures we never emit non-PDF paths +// even if a user explicitly passes one on the second-instance command line. +func resolveSecondInstancePaths(args []string, cwd string) []string { + pdfs := filterPDFPaths(args) + out := make([]string, 0, len(pdfs)) + for _, p := range pdfs { + if !filepath.IsAbs(p) { + p = filepath.Join(cwd, p) + } + out = append(out, p) + } + return out +} + +// onSecondInstance is the Wails SingleInstanceLock callback. Forwards the +// args from a second-launch process to the frontend as "open-files". Skips +// emitting when the second instance had no PDF args so a bare relaunch +// doesn't pop a "No PDF files" toast in the first window. +func (a *App) onSecondInstance(data options.SecondInstanceData) { + paths := resolveSecondInstancePaths(data.Args, data.WorkingDirectory) + if len(paths) == 0 { + return + } + runtime.EventsEmit(a.ctx, "open-files", paths) +} + +// newWindowEnv returns a copy of env with any prior LANKIR_NEW_WINDOW entry +// removed and a single LANKIR_NEW_WINDOW=1 appended. The child process uses +// this signal to skip SingleInstanceLock acquisition (wired in main.go). +func newWindowEnv(env []string) []string { + out := make([]string, 0, len(env)+1) + for _, e := range env { + if !strings.HasPrefix(e, "LANKIR_NEW_WINDOW=") { + out = append(out, e) + } + } + return append(out, "LANKIR_NEW_WINDOW=1") +} + +// OpenInNewWindow spawns a detached lankir process with the same executable +// and the given file path as an argument. Bypasses SingleInstanceLock via +// LANKIR_NEW_WINDOW=1. Empty filePath spawns an empty new window. +// +// Exported because the frontend calls it via Wails bindings (App.OpenInNewWindow). +func (a *App) OpenInNewWindow(filePath string) error { + exe, err := os.Executable() + if err != nil { + return err + } + args := []string{} + if filePath != "" { + args = append(args, filePath) + } + cmd := exec.Command(exe, args...) + cmd.Env = newWindowEnv(os.Environ()) + return cmd.Start() +} diff --git a/app_test.go b/app_test.go index 57e5410..d014e81 100644 --- a/app_test.go +++ b/app_test.go @@ -2,6 +2,10 @@ package main import ( "context" + "os" + "path/filepath" + "reflect" + "strings" "testing" "github.com/wailsapp/wails/v2/pkg/runtime" @@ -278,3 +282,63 @@ func TestApp_ErrorHandling(t *testing.T) { // We skip this test as it requires Wails runtime t.Skip("Requires Wails runtime context (tested in E2E)") } + +func TestFilterPDFPaths(t *testing.T) { + got := filterPDFPaths([]string{"a.pdf", "b.txt", "C.PDF", "/x/y/z.png", "doc.pdf"}) + want := []string{"a.pdf", "C.PDF", "doc.pdf"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v want %v", got, want) + } +} + +func TestResolveSecondInstancePaths(t *testing.T) { + cwd, err := os.MkdirTemp("", "lankir-test-cwd-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(cwd) + + abs := filepath.Join(cwd, "abs.pdf") + got := resolveSecondInstancePaths([]string{"rel.pdf", abs, "other.txt"}, cwd) + + want := []string{ + filepath.Join(cwd, "rel.pdf"), + abs, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v want %v", got, want) + } +} + +func TestNewWindowEnv(t *testing.T) { + in := []string{"PATH=/bin", "LANKIR_NEW_WINDOW=1", "HOME=/root", "LANKIR_NEW_WINDOW=stale"} + got := newWindowEnv(in) + + // Must contain exactly one LANKIR_NEW_WINDOW=1 (we strip any prior and re-add). + count := 0 + for _, e := range got { + if e == "LANKIR_NEW_WINDOW=1" { + count++ + } + if strings.HasPrefix(e, "LANKIR_NEW_WINDOW=") && e != "LANKIR_NEW_WINDOW=1" { + t.Errorf("non-canonical LANKIR_NEW_WINDOW entry: %q", e) + } + } + if count != 1 { + t.Errorf("expected exactly one LANKIR_NEW_WINDOW=1; got %d in %v", count, got) + } + + // Other env entries preserved. + hasPath, hasHome := false, false + for _, e := range got { + if e == "PATH=/bin" { + hasPath = true + } + if e == "HOME=/root" { + hasHome = true + } + } + if !hasPath || !hasHome { + t.Errorf("non-LANKIR env not preserved: %v", got) + } +} diff --git a/cmd/cli/root.go b/cmd/cli/root.go index b917de2..58bda2b 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -25,19 +25,52 @@ var rootCmd = &cobra.Command{ - Digital signatures with PKCS#11, PKCS#12, and NSS support - Certificate management - Signature profiles -- Configuration management`, +- Configuration management + +Passing a PDF path as the first argument opens it in the GUI: + lankir document.pdf + +If you have a file literally named after a subcommand (e.g. "pdf"), pass it +with a leading "./" to disambiguate: + lankir ./pdf +`, PersistentPreRun: func(cmd *cobra.Command, args []string) { setupLogger() }, } func Execute(runGUI func()) { - guiFunc = runGUI + ExecuteWithArgs(os.Args[1:], func([]string) { runGUI() }) +} + +// ExecuteWithArgs runs the Cobra root command with explicit args. The runGUI +// callback is wired so that the `gui` subcommand opens an empty GUI; passing +// startup files happens via main()'s direct call to runGUI(initialFiles). +func ExecuteWithArgs(args []string, runGUI func([]string)) { + guiFunc = func() { runGUI(nil) } + rootCmd.SetArgs(args) if err := rootCmd.Execute(); err != nil { os.Exit(1) } } +// IsKnownSubcommand reports whether name is a registered Cobra subcommand on +// the root command (or one of its aliases). Used by main.go to disambiguate +// file paths from CLI verbs. +func IsKnownSubcommand(name string) bool { + for _, c := range rootCmd.Commands() { + if c.Name() == name { + return true + } + for _, alias := range c.Aliases { + if alias == name { + return true + } + } + } + return false +} + var guiFunc func() func init() { diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js index 3d04e59..420ba15 100644 --- a/frontend/src/js/app.js +++ b/frontend/src/js/app.js @@ -10,7 +10,9 @@ import { initMessageDialog } from './messageDialog.js'; import { initSettings, getSetting } from './settings.js'; import { themeManager } from './themeManager.js'; import { initLoadingIndicator } from './loadingIndicator.js'; -import { state } from './state.js'; +import { state, getActivePDF } from './state.js'; +import { handleOpenFiles, suppressWebViewDropDefault } from './fileOpener.js'; +import * as runtime from '../wailsjs/runtime/runtime.js'; document.addEventListener('DOMContentLoaded', async () => { @@ -35,6 +37,9 @@ document.addEventListener('DOMContentLoaded', async () => { updateStatus('Ready'); loadRecentFilesWelcome(); + runtime.EventsOn('open-files', handleOpenFiles); + suppressWebViewDropDefault(); + const leftSidebar = document.getElementById('leftSidebar'); const rightSidebar = document.getElementById('rightSidebar'); const expandLeft = document.getElementById('expandLeft'); @@ -135,6 +140,16 @@ document.addEventListener('DOMContentLoaded', async () => { return; } + // New window (Ctrl+Shift+N) — spawn a separate lankir process via SingleInstanceLock bypass + if (matchShortcut(e, cfg.newWindow || 'Control+Shift+N')) { + e.preventDefault(); + const active = getActivePDF(); + window.go.main.App.OpenInNewWindow(active?.filePath ?? '').catch(err => { + console.error('OpenInNewWindow failed:', err); + }); + return; + } + // Sign PDF if (!isTyping && matchShortcut(e, cfg.sign || 'Alt+s')) { e.preventDefault(); diff --git a/frontend/src/js/fileOpener.js b/frontend/src/js/fileOpener.js new file mode 100644 index 0000000..dc0f166 --- /dev/null +++ b/frontend/src/js/fileOpener.js @@ -0,0 +1,49 @@ +import { createPDFTab, switchToTab } from './pdfManager.js'; +import { showMessage } from './messageDialog.js'; + +/** + * Workaround for wailsapp/wails#3686 (closed 2025-08-02 with this fix + * accepted by the maintainer). On Linux WebKit2GTK, dropping a file on the + * window triggers WebKit's default file-navigation in addition to Wails' + * Go-side OnFileDrop callback, hijacking the app UI with a chrome-style + * file viewer. Calling preventDefault on every drag-related JS event + * blocks the WebKit default; Wails' Go callback still fires via the GTK + * signal path and is unaffected. + */ +export function suppressWebViewDropDefault() { + const events = [ + 'drop', 'dragover', 'dragleave', 'drag', 'dragend', 'dragenter', 'dragstart', + ]; + for (const evt of events) { + window.addEventListener(evt, (e) => e.preventDefault()); + } +} + +/** + * Open each path as a new tab in the current window. Skips empty input with a + * single info toast; per-file errors get a toast and don't stop the batch. + * + * Funnels three sources: drag-drop (Wails OnFileDrop emits "open-files"), + * OS "Open with" via SingleInstanceLock, and startup `initialFiles`. All + * three emit the Wails "open-files" event; subscribe in app.js with + * `runtime.EventsOn('open-files', handleOpenFiles)`. + * + * @param {string[]} paths Absolute file paths. + */ +export async function handleOpenFiles(paths) { + if (!Array.isArray(paths) || paths.length === 0) { + showMessage('No PDF files in drop', 'Open files', 'info'); + return; + } + + let lastTab = null; + for (const path of paths) { + try { + const metadata = await window.go.pdf.PDFService.OpenPDFByPath(path); + lastTab = createPDFTab(metadata.filePath, metadata); + } catch (err) { + showMessage(`Failed to open ${path}: ${err?.message ?? err}`, 'Open failed', 'error'); + } + } + if (lastTab) switchToTab(lastTab); +} diff --git a/frontend/src/style.css b/frontend/src/style.css index e71f5fd..a081e5c 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1342,4 +1342,5 @@ #pdfTabsContainer::-webkit-scrollbar-thumb:hover { @apply bg-text-secondary; } -} \ No newline at end of file +} + diff --git a/frontend/tests/fileOpener.test.js b/frontend/tests/fileOpener.test.js new file mode 100644 index 0000000..0d17d7e --- /dev/null +++ b/frontend/tests/fileOpener.test.js @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const openMock = vi.fn(); +const createTabMock = vi.fn(); +const switchTabMock = vi.fn(); +const showMessageMock = vi.fn(); + +vi.mock('../src/js/pdfManager.js', () => ({ + createPDFTab: createTabMock, + switchToTab: switchTabMock, +})); +vi.mock('../src/js/messageDialog.js', () => ({ + showMessage: showMessageMock, +})); + +beforeEach(() => { + openMock.mockReset(); + createTabMock.mockReset(); + switchTabMock.mockReset(); + showMessageMock.mockReset(); + globalThis.window = globalThis.window || {}; + window.go = { pdf: { PDFService: { OpenPDFByPath: openMock } } }; +}); + +describe('handleOpenFiles', () => { + it('opens a single PDF as a new tab and switches to it', async () => { + openMock.mockResolvedValue({ filePath: '/x/foo.pdf', pageCount: 1 }); + createTabMock.mockReturnValue('tab-1'); + + const { handleOpenFiles } = await import('../src/js/fileOpener.js'); + await handleOpenFiles(['/x/foo.pdf']); + + expect(openMock).toHaveBeenCalledWith('/x/foo.pdf'); + expect(createTabMock).toHaveBeenCalledWith('/x/foo.pdf', expect.any(Object)); + expect(switchTabMock).toHaveBeenCalledWith('tab-1'); + }); + + it('shows a toast and skips backend calls when batch is empty', async () => { + const { handleOpenFiles } = await import('../src/js/fileOpener.js'); + await handleOpenFiles([]); + + expect(openMock).not.toHaveBeenCalled(); + expect(createTabMock).not.toHaveBeenCalled(); + expect(showMessageMock).toHaveBeenCalledWith( + expect.stringMatching(/no pdf/i), + expect.any(String), + 'info' + ); + }); + + it('shows a per-file toast on backend error and continues', async () => { + openMock.mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce({ filePath: '/x/b.pdf' }); + createTabMock.mockReturnValue('tab-b'); + + const { handleOpenFiles } = await import('../src/js/fileOpener.js'); + await handleOpenFiles(['/x/a.pdf', '/x/b.pdf']); + + expect(openMock).toHaveBeenCalledTimes(2); + expect(showMessageMock).toHaveBeenCalledWith(expect.stringMatching(/a\.pdf/), expect.any(String), 'error'); + expect(createTabMock).toHaveBeenCalledTimes(1); + expect(createTabMock).toHaveBeenCalledWith('/x/b.pdf', expect.any(Object)); + }); +}); diff --git a/main.go b/main.go index 0457bf2..609b9c4 100644 --- a/main.go +++ b/main.go @@ -5,11 +5,13 @@ import ( "embed" "log" "os" + "strings" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" "github.com/wailsapp/wails/v2/pkg/options/linux" + "github.com/wailsapp/wails/v2/pkg/runtime" "github.com/Matbe34/lankir/cmd/cli" "github.com/Matbe34/lankir/internal/config" @@ -20,9 +22,29 @@ import ( //go:embed all:frontend/dist var assets embed.FS +// parseArgs splits process args into CLI args (route to Cobra) vs GUI files +// (open as tabs in the GUI). Cobra subcommand or any leading `-` flag → CLI. +// Otherwise everything is treated as a GUI file path. +func parseArgs(args []string) (cliArgs, guiFiles []string) { + if len(args) == 0 { + return nil, nil + } + for _, a := range args { + if strings.HasPrefix(a, "-") { + return args, nil + } + if cli.IsKnownSubcommand(a) { + return args, nil + } + break + } + return nil, args +} + func main() { - if len(os.Args) == 1 { - runGUI() + cliArgs, guiFiles := parseArgs(os.Args[1:]) + if cliArgs == nil { + runGUI(guiFiles) return } @@ -31,11 +53,14 @@ func main() { // No-op on Linux/macOS. attachParentConsole() - cli.Execute(runGUI) + cli.ExecuteWithArgs(cliArgs, runGUI) } -func runGUI() { +func runGUI(initialFiles []string) { + skipLock := os.Getenv("LANKIR_NEW_WINDOW") == "1" + app := NewApp() + app.initialFiles = initialFiles configService, err := config.NewService() if err != nil { @@ -51,9 +76,21 @@ func runGUI() { pdfService.Startup(ctx) recentFilesService.Startup(ctx) signatureService.Startup(ctx) + + // Wire drag-drop. The Wails v2 fork in our go.mod replace directive + // patches the Linux GTK handler to return TRUE so WebKit's default + // file-navigation does not hijack the window. See wailsapp/wails#3686 + // and Matbe34/wails fix/linux-drag-drop-return-true branch. + runtime.OnFileDrop(ctx, app.onFileDrop) + + // Hand off any startup files (from CLI args or OS "Open with") to the + // frontend via the same event used by SingleInstanceLock. + if len(app.initialFiles) > 0 { + runtime.EventsEmit(ctx, "open-files", app.initialFiles) + } } - err = wails.Run(&options.App{ + opts := &options.App{ Title: "Lankir", Width: 1400, Height: 900, @@ -74,9 +111,19 @@ func runGUI() { WindowIsTranslucent: false, WebviewGpuPolicy: linux.WebviewGpuPolicyAlways, }, - }) + DragAndDrop: &options.DragAndDrop{ + EnableFileDrop: true, + }, + } - if err != nil { + if !skipLock { + opts.SingleInstanceLock = &options.SingleInstanceLock{ + UniqueId: "com.lankir.singleinstance", + OnSecondInstanceLaunch: app.onSecondInstance, + } + } + + if err := wails.Run(opts); err != nil { log.Fatal("Error:", err.Error()) } } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..dab95f2 --- /dev/null +++ b/main_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestParseArgs(t *testing.T) { + cases := []struct { + name string + args []string + wantCLI []string + wantGUI []string + }{ + {"no args", nil, nil, nil}, + {"cobra subcommand", []string{"pdf", "info", "x.pdf"}, []string{"pdf", "info", "x.pdf"}, nil}, + {"single pdf path", []string{"foo.pdf"}, nil, []string{"foo.pdf"}}, + {"multiple pdf paths", []string{"a.pdf", "b.pdf"}, nil, []string{"a.pdf", "b.pdf"}}, + {"global flag before subcommand", []string{"--verbose", "pdf", "info", "x.pdf"}, []string{"--verbose", "pdf", "info", "x.pdf"}, nil}, + {"dot-slash workaround", []string{"./pdf"}, nil, []string{"./pdf"}}, + {"unknown subcommand routes to GUI", []string{"pdf-info", "x.pdf"}, nil, []string{"pdf-info", "x.pdf"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cli, gui := parseArgs(tc.args) + if !reflect.DeepEqual(cli, tc.wantCLI) { + t.Errorf("CLI: got %v want %v", cli, tc.wantCLI) + } + if !reflect.DeepEqual(gui, tc.wantGUI) { + t.Errorf("GUI: got %v want %v", gui, tc.wantGUI) + } + }) + } +} diff --git a/packaging/lankir.desktop b/packaging/lankir.desktop index 4f549aa..d76a8dc 100644 --- a/packaging/lankir.desktop +++ b/packaging/lankir.desktop @@ -1,10 +1,11 @@ [Desktop Entry] Name=Lankir Comment=Modern PDF Editor and Signer for Linux -Exec=/usr/bin/lankir +Exec=/usr/bin/lankir %f Icon=lankir Terminal=false Type=Application Categories=Office;Productivity; Keywords=pdf;sign;signature;document;editor; StartupWMClass=Lankir +MimeType=application/pdf; diff --git a/wails.json b/wails.json index a48d250..43b82f1 100644 --- a/wails.json +++ b/wails.json @@ -11,7 +11,7 @@ "info": { "companyName": "Lankir", "productName": "Lankir", - "productVersion": "0.1.1", + "productVersion": "0.1.2-rc1", "copyright": "Copyright © 2025", "comments": "A modern PDF editor for Linux" },