Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
81a2634
feat: parseArgs splits GUI files from CLI subcommand args
Matbe34 May 4, 2026
dfb6d1a
refactor: route parsed args through runGUI initialFiles
Matbe34 May 4, 2026
39debdd
feat: filter dropped paths to PDFs and emit open-files event
Matbe34 May 4, 2026
8e16931
feat: resolve and forward second-instance paths to frontend
Matbe34 May 4, 2026
c1f3ec2
feat: OpenInNewWindow spawns detached lankir with env signal
Matbe34 May 4, 2026
79786e2
feat: enable wails drag-drop and single-instance lock
Matbe34 May 4, 2026
0183a91
build: register application/pdf MIME on linux
Matbe34 May 4, 2026
3f4a212
feat: add fileOpener as single source for opening PDFs
Matbe34 May 4, 2026
9c70662
docs: clarify GUI vs CLI dispatch in --help
Matbe34 May 4, 2026
9e36ff7
feat: subscribe to open-files event in main app init
Matbe34 May 4, 2026
b80cc5f
feat: mark viewer and welcome screen as drop targets
Matbe34 May 4, 2026
5e6dc02
feat: add Ctrl+Shift+N to spawn a new lankir window
Matbe34 May 4, 2026
be06fec
fix: disable webview drop so dragged PDFs don't hijack the window
Matbe34 May 5, 2026
11b47ff
fix: route all drops to wails handler and link against webkit2gtk-4.1
Matbe34 May 5, 2026
43d0755
fix: extend drop-target marker to body for full-window drop coverage
Matbe34 May 5, 2026
d9c2cc3
fix: replace broken wails native drag-drop with html5 implementation
Matbe34 May 5, 2026
ba6652a
fix: use patched wails fork for working linux drag-drop
Matbe34 May 5, 2026
b95b2d1
fix: suppress webview drop default via JS preventDefault (wails#3686 …
Matbe34 May 5, 2026
9ee2669
fix: suppress empty drop and second-instance fires to silence false t…
Matbe34 May 5, 2026
6a88dfd
chore: bump version to 0.1.2-rc1 for entry-points test build
Matbe34 May 5, 2026
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
2 changes: 1 addition & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
93 changes: 92 additions & 1 deletion app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
}
64 changes: 64 additions & 0 deletions app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ package main

import (
"context"
"os"
"path/filepath"
"reflect"
"strings"
"testing"

"github.com/wailsapp/wails/v2/pkg/runtime"
Expand Down Expand Up @@ -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)
}
}
37 changes: 35 additions & 2 deletions cmd/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
17 changes: 16 additions & 1 deletion frontend/src/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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');
Expand Down Expand Up @@ -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();
Expand Down
49 changes: 49 additions & 0 deletions frontend/src/js/fileOpener.js
Original file line number Diff line number Diff line change
@@ -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);
}
3 changes: 2 additions & 1 deletion frontend/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1342,4 +1342,5 @@
#pdfTabsContainer::-webkit-scrollbar-thumb:hover {
@apply bg-text-secondary;
}
}
}

Loading
Loading