99 Commits
Author SHA1 Message Date
yusufipek 22a73bd939 Merge master into the OpenCode Go branch
Master grew Google AI Studio and Antigravity as providers, a doctor that
names each provider's own key, and settings that fetch every hosted
model list as the window opens. OpenCode Go is folded into each: its
name joins SERVICES and the doctor's key table, its key row sits beside
Google's, its Fetch button follows the per-provider pattern Google's
uses, and _load_hosted_models fetches its catalog at open when a key is
on file, filling the cleanup and agent boxes alike.
2026-08-27 16:27:57 +03:00
Yusuf İpek 5fd32d105f Merge pull request #59 from Oztturk/gemini-and-agy-cleanup
Clean up on Google AI Studio, or on Antigravity
2026-08-27 16:14:34 +03:00
yusufipekandClaude Fable 5 d331ccb877 Fetch every model list at open, without being asked
Codex's list already arrived on its own, because reading a cache on disk
costs nothing. The other three lists were behind a Fetch button, which
meant the built-in ones aged in front of anyone who never pressed it. Now
OpenRouter's and Google's lists are fetched when the settings window
opens, and Antigravity's comes off `agy models`, which prints one
id-tab-name line per model and answers over the network in a couple of
seconds; all three run off the interface thread.

Nobody asked, so nothing is reported: a failure changes nothing on
screen, the built-in lists stay, and the Fetch buttons remain both the
retry and the place an error is worth explaining. A provider whose key
has not been given is not called at all, so opening Settings is not by
itself a request to two vendors.

Also drops an import the merge had left in twice.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-27 16:12:18 +03:00
yusufipek fa3d72f5a7 Fetch OpenCode Go's catalog as the settings window opens
Codex already refreshes its boxes from the source at open, so the
built-in list is never the whole truth for longer than a window takes to
build. OpenCode Go now gets the same courtesy: one request to /models on
a background thread, both of its boxes refilled with the picked model
kept, skipped entirely when there is no key to send.
2026-08-27 16:04:30 +03:00
Yusuf İpek 7b7df1df62 Merge pull request #60 from AydoganCan60/feature/display-selection
Add display selection for recording indicator
2026-08-27 15:54:30 +03:00
yusufipek 65055eeb56 Give OpenCode Go a reachable Fetch model list button
The one button lived in the OpenRouter model row, which leaves the
screen whenever another provider is chosen, so the OpenCode fetch path
could never be clicked. The OpenCode row now carries its own button into
the same handler, the row as a whole is what hides, and the fetched list
lands in the box it belongs to without touching the meeting box.
2026-08-27 15:53:44 +03:00
yusufipek ccc8ba596e Merge master into the OpenCode Go branch
Codex now asks itself for its model list, doctor grew ready flags and a
per-provider line, and the Groq key joined the masked ones; the OpenCode
additions are folded into each. The no-CLI cleanup test follows the
fake_run to fake_cli rename.
2026-08-27 15:51:49 +03:00
yusufipek 1df2d03056 Merge master into the Gemini and Antigravity branch
Both sides rewrote the doctor: master rebuilt it around ready flags so a
fully local setup stops reading as broken, while this branch taught it
that Google has a key of its own and that the local model has neither a
key nor a program. Kept master's structure and folded the branch in: the
two hosted providers answer for their keys, a CLI for its program, and
the JSON keeps both the provider-aware "key" and master's "ready".

SECRET_KEYS gained groq on master and gemini here; the resolution keeps
all four. _conclude was also changed by both: master stopped matching
stderr wording and asks _API_TROUBLE instead, this branch renamed its
last parameter to the provider's short name so a session is stored under
the name it is read back by; the rename now rides on master's body.
codex_models() and the settings loaders landed beside the new Gemini
ones, so both stay. The new cleanup tests called the CLI fake by its old
name, fake_run, which master had renamed to fake_cli.
2026-08-27 15:50:48 +03:00
yusufipek f6c6122253 Merge master into the transcript queue branch 2026-08-27 15:35:08 +03:00
Yusuf İpek 9736bd0ecd Merge pull request #54 from yusufipk/codex-model-list
Ask Codex itself which models it offers
2026-08-27 15:31:24 +03:00
Yusuf İpek d896680a4f Merge pull request #50 from huseyin-emre-tigci/single-instance
Survive every way a dictation was being lost
2026-08-27 15:28:05 +03:00
yusufipek 17a55e1efc Merge master into the reliability branch
The paste block was rewritten by both sides: master taught press() to
put the remembered application back in front (focus), this branch moved
the history write ahead of the paste and stopped restoring the old
clipboard over a transcript the key press refused. Kept this branch's
order and error handling, and handed press() the focus it now takes.
2026-08-27 15:23:41 +03:00
yusufipek a3bb5ed29b Keep the rebuild away from a window a thread is still writing to
deleteLater destroyed a window whose model boxes a daemon thread was
still reporting into: a running download died with a RuntimeError, the
.part stayed on disk and the UI said nothing. Dropping the reference is
how the ordinary close path lets a window go, and the closures a thread
holds keep the object alive until it is done, so the rebuild now does
the same. While the transcriber or either model box is working the
rebuild is skipped altogether; _built_language keeps the old language,
so the next quiet save asks for it by itself.

The new window is also placed and turned to the old tab before it is
shown, so it no longer comes up at the default size and jumps. And
tests/test_ui.py now says that a save with a changed language emits
language_changed, and one without does not.
2026-08-27 15:13:54 +03:00
Aydogan e6208418f9 Add display selection for recording indicator 2026-08-27 07:46:34 +03:00
oztturkandClaude Opus 5 7ebc3bf825 Ask Google for the lowest rung it has rather than for none
Google's compatibility layer has no word for off. Sending
reasoning_effort "none" is refused outright, so choosing Thinking → Off
made every cleanup fail and paste the raw transcript instead:

  HTTP 400: Request contains an invalid argument. (INVALID_ARGUMENT)

Measured against gemini-3.5-flash-lite, asking it to reply "ok":

  nothing sent               50.59s
  reasoning_effort "none"    400
  reasoning_effort "minimal" 15.40s
  reasoning_effort "low"     53.35s
  reasoning_effort "high"    63.83s

So "none" lands on "minimal", which is both accepted and the quickest of
them, and quickest is what cleanup wants. The thinking_config route the
documentation offers is an SDK wrapper and is not a field this endpoint
knows: sending it is "Unknown name \"google\"".

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-26 16:40:27 +03:00
oztturkandClaude Opus 5 1ffc3cff9d Let an error body that is an array still be read
Google answers some failures with a JSON array holding the object every
other provider sends on its own. _extract_error called .get() on it and
raised AttributeError, which is not the ApiError every caller is holding,
so a 503 from Google took the whole dictation down instead of pasting the
raw transcript with the failure shown beside it.

Found by dictating against a Google AI Studio outage:

  HTTP Error 503: Service Unavailable
  AttributeError: 'list' object has no attribute 'get'

It runs while an exception is being raised, so it now ends in a string
whatever arrives.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-26 16:29:41 +03:00
oztturkandClaude Opus 5 1812461612 Clean up on Google AI Studio, or on Antigravity
OpenRouter's free tier rate-limits and carries no free Gemini model, and
cleaning up through Claude Code costs a fixed few seconds because it opens
a whole CLI session to drop three "uh"s. Google's own free tier suits a
short, frequent request, and its OpenAI-compatible endpoint answers
/chat/completions, so cleanup there is one request and the same code path
OpenRouter already takes.

The one thing that is not shared is the thinking level. Google reads
OpenAI's flat reasoning_effort rather than OpenRouter's object, and "none"
is how thinking is turned off, so it is sent rather than skipped: a Flash
model left to think spends exactly the second this provider was chosen to
save. Its top two rungs land on "high", which is as far as Google goes.

Speech to text stays where it was. That endpoint has no
/audio/transcriptions behind it, audio only goes in as base64 inside a
chat message, and what comes back has none of the segment times a subtitle
file or a meeting transcript is built out of.

Antigravity joins as well, on cleanup and as an agent. It is a CLI like the
other two and costs the same session, so it is here for people who already
pay for it rather than as an answer to the speed. It takes neither an empty
tool list nor a read-only sandbox, and cleanup.py now says so plainly
instead of implying parity; what it gets is a project of its own, the home
directory, and its slash commands off.

Three things were already wrong and are fixed on the way past, because the
new providers walk the same paths: doctor raised KeyError on the local
model, whose executable is ""; the history recorded Claude's model whoever
answered; and every agent row read "asked Claude".

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-26 16:23:04 +03:00
sudoeren e6b3fcf48f Reach OpenCode Go from the command line
test-key accepts opencode and reports the model count its /models
endpoint offers, ask --provider accepts it and maps --model to its own
setting, doctor reports its key for cleanup on it, and config list
masks the key like the other two.
2026-08-25 21:05:23 +03:00
sudoeren 3c336e7178 Offer OpenCode Go in the settings window
A key row under Keys, a model box in the cleanup tab and a box in the
agent tab, each with its own model list of the models OpenCode Go
serves over /chat/completions. Fetch model list reads whichever cleanup
provider is on screen, and the Turkish strings cover the new rows.
2026-08-25 21:05:21 +03:00
sudoeren 0a6c6128a2 Add OpenCode Go as a cleanup and agent provider
OpenCode Go serves open coding models from an OpenAI-compatible endpoint. It cannot transcribe, so it joins
the two LLM jobs: transcript cleanup and the plain-chat agent. The key
falls back to OPENCODE_API_KEY, and api.chat() stops hardcoding the
OpenRouter name so the same call serves both.
2026-08-25 21:05:18 +03:00
yusufipek be09cc3ac6 Let the next dictation start while the last one is still working
Pressing the shortcut while a transcript was being transcribed or cleaned up
did nothing, and the thought you had while waiting was lost. The microphone is
free the moment a recording stops, so the next dictation can now be spoken at
once; the pipeline queues it and each one is finished, pasted and reported in
the order it was spoken. The corner indicator stays with the recording under
way rather than being wiped by the previous run's progress, and a stop that
lands behind an unfinished run says it is waiting its turn.
2026-08-25 14:52:17 +03:00
yusufipek e9488a51a4 Ask Codex itself which models it offers
The Codex model boxes carried a hand-written list, which was already out of
date. The settings window now asks `codex debug models` for the catalog the
CLI's own picker reads, off the interface thread, and refills both boxes with
it; the built-in list is only what is on screen until Codex answers, or when
it is not installed at all.
2026-08-25 14:42:19 +03:00
Gökhan 901e57e4d0 Merge master into the macOS front branch 2026-08-25 00:16:20 +03:00
huseyin-emre-tigciandClaude Fable 5 e8147f49f8 Say true things in every language, and stop paying twice
doctor judged the local provider by the API key it does not use, so a
fully local machine always saw a red mark, and it crashed outright with
local cleanup picked; both lines now ask readiness. config list printed
the Groq key in plaintext while masking the other two. The one subprocess
decoded with the locale codepage gets its UTF-8 back, so a Turkish
filename cannot hang a file transcription, and a redirected stdout on
Windows replaces what it cannot encode instead of failing after the work
succeeded. meeting-cancel stops advertising a --wait the server never
honoured. "you" and "bye" leave the hallucination list: people dictate
them. The minutes stage failing no longer burns the transcription
checkpoint, and an untouched meeting-length dial no longer rewrites a
value the command line set in seconds. A prompt box compared against the
wrong language's default after a switch no longer fossilizes the old
default as a custom prompt.

The 67 strings of the local-model box, the whole first-run screen of the
shipped default, get their Turkish. The hub cache moves to the platform's
cache directory instead of ~/.cache on every system; the old directory is
a few orphaned kilobytes with a six-hour shelf life. The last NO_WINDOW
spellings collapse into the constant paths already carries, one
windowed-executable lookup, one session-file reader, one install-record
reader, one download progress signal carrying its destination, and the
KDE conflict scan loses the branch its other branch already covered.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 23:17:22 +03:00
huseyin-emre-tigciandClaude Fable 5 6f79e93d53 Let no failure eat a dictation, and none strand the state machine
The recorded WAV was deleted in a finally that did not care why the run
ended, so a whisper server being down destroyed the only copy of the
user's speech; a failed run now keeps its audio in the recordings
directory and names the path in the error. The history was written only
after the paste, and a failed key press restored the previous clipboard
over the fresh transcript: text not pasted, not on the clipboard, not in
the history, audio gone, all from one refused key. The history now comes
first, a failed press is a warning the row is amended to carry, and the
clipboard keeps the text the user has to paste by hand. Kept recordings
no longer overwrite each other inside one second, the keep_audio move
failing no longer falls through to the delete, and the history row
records whether cleanup actually ran rather than what the dictation gate
implies about an ask.

In the application: a recorder that failed to start emitted its error
synchronously and start() then wrote RECORDING over the handler's IDLE,
one more key press away from a BUSY nothing would ever end; start now
checks the recorder is running, like start_meeting always has. The new
died signal ends the run properly and transcribes what was captured. A
--paste override armed by a request that no-opped stopped haunting some
later unrelated run, and it dies with a cancelled or failed one. The
toggle, ask and pause debounce timers are per action, so a pause right
after a toggle is a pause and not a duplicate. Waiters outstanding at
quit or restart are settled instead of being read back as "the instance
is too old". The listing warm-up and the one-per-press backend lookup
move off the key press.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 23:17:22 +03:00
huseyin-emre-tigciandClaude Fable 5 69194186c8 Kill the agent's whole tree, and stop reading its errors as prose
The claude and codex CLIs spawn shells to do their work, and killing only
the direct child left those shells holding the pipes: the stdout loop
never saw EOF, so the run hung forever with the watchdog already fired,
and cleanup's subprocess.run could hang inside the stdlib the same way
after its own timeout. Both now run the CLI with its output in files
rather than pipes and put the whole tree down on a timeout, taskkill /T
on Windows and a process group everywhere else. Whether a dead resume
means "start a fresh conversation" was decided by English substrings of
stderr, which a localized CLI never says; a resumed run that exits
nonzero with no answer now retries fresh once, whatever the words were.
Recognised API trouble is the exception: a spent quota, a signed-out CLI
or a dead network is not the session's fault, and is reported as itself
rather than retried into a second copy of the same failure.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 23:17:22 +03:00
huseyin-emre-tigciandClaude Fable 5 afc8ffd992 Hold the settings and the two indexes the way files are lost
history.jsonl and meetings.jsonl were rewritten read-modify-replace from
two threads with nothing between them, so a dictation landing during a
trim, or a meeting status landing during a delete, was silently gone; one
lock now wraps every touch of either file. The config rename gets a flush
and fsync in front of it, because a rename that survives a power loss
ahead of its data is an empty settings file, and a corrupt file is set
aside as config.json.broken instead of being quietly replaced by defaults
on the next save, API keys and all. Every replace retries briefly on
Windows, where an antivirus or a sync tool holds a fresh file for a beat
and one PermissionError out of a Qt slot takes the whole application
down.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 23:17:22 +03:00
huseyin-emre-tigciandClaude Fable 5 197f5385ee Never let an install or a sweep destroy what still works
install_program deleted the working install before the download had even
started, so a network failure, or the running server's own locked DLLs,
left "whisper.cpp is not installed" behind on a machine where it had
been. The order is now: download, unpack beside, stop the server whose
binary lives there, swap, so the outage is the swap and not the whole
transfer, and a download that fails never takes the server down at all.
A re-downloaded model the server still holds open no longer costs the
finished download; the .part survives and the message says who is holding
the file.

sweep() forgot the pid file before verifying or killing, so one transient
error orphaned a loaded model forever; it verifies, kills, then forgets,
and a verification that could not run leaves the file for the next start.
On Windows the ownership check was the executable's basename, which a
recycled pid could satisfy with somebody else's server; it is the full
image path now, read into a buffer that grows past 260 characters.
stop() takes the launch lock, so stopping during a start kills the server
the start was making rather than missing it, and _forget only removes a
record that is still its own. The relaunch retry stopped reading English
out of the log tail: the retryable failure is a child that exited without
ever listening, and that is what is tested, along with the child still
being alive once its port answers.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 23:17:22 +03:00
huseyin-emre-tigciandClaude Fable 5 420cda9376 Keep the recorder alive through the ways a capture dies
Four holes in one class, found by a review the last one paid for. The
capture's stderr went to a pipe nobody drained, so a chatty ffmpeg could
fill it and freeze the recording; it goes to a file now, the way the
meeting recorder always did. A capture dying with audio already buffered
ended the pump in silence, the clock counting over a dead microphone;
there is a died signal now, and the application transcribes what was
caught. A pump that outlived its two-second join could write stale audio
into the next recording and kill the next recording's process; each run
now owns its objects and a token. Short pipe reads were each billed a
whole chunk in the silence math, overstating speech; the pump reads exact
chunks, as meetings do.

Smaller ones alongside: write_wav failing no longer strands the state
machine; a deliberate stop no longer reports ffmpeg's interrupt code as
"Nothing was recorded: ffmpeg -> 255"; kills are reaped so no message
says "exit code None"; the pw-record probe is paid once per process; the
RMS loop uses sumprod where Python has it; the pactl and pw-record
listings decode as the UTF-8 they are. paths gains the NO_WINDOW constant
this module re-exports, the one spelling every subprocess site after it
shares.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 23:17:22 +03:00
huseyin-emre-tigciandClaude Fable 5 6f601ab969 Let a second copy yield to the instance already running
listen() was the whole of the single-instance check and it cannot be one:
a Windows named pipe takes a second server on the same name rather than
refusing it, and everywhere else removeServer() first takes the live socket
away from the instance holding it. Starting Dikte over a running Dikte then
left two whole copies up, two tray icons and all, and the newer one's
sweep() killed the whisper the older one was answering dictations with. On
a machine that sleeps instead of logging out, that is one Start Menu click
away, and it cost a real dictation before it was understood.

A QLockFile in the data directory closes the race on all three systems,
taken before the QApplication is even built, and behind it the probe is the
side-effect-free status verb: the Settings window opens as the sign of life
only when a bare second start deliberately asks for it, not as a byproduct
of a probe racing a forwarded toggle. The two Windows relaunch dances
collapse into one ipc.respawn.

The checkout installer and the packaged setup each kept an autostart the
other could not see, so a machine that tried both started two copies at
every sign-in: each autostart now removes the other's entry, the silent
every-start repair backs off from a Run value whose target still exists,
and each uninstaller deletes the shared dikte.cmd only when the shim
names its own install.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 23:09:05 +03:00
yusufipek 084c48151d Say when a newer release is out, and stop there
A check on the releases page once a day: at start, on a timer while Dikte
runs, from the General tab on demand, and from `dikte update` at a terminal.
What it finds goes in the tray menu and in one notification per version, and
opens the release page.

Nothing is downloaded and nothing is installed. The four downloads are
installed four different ways and three of those belong to the platform: a
Mac bundle cannot rewrite itself while it is running, the Windows setup has
an uninstall entry of its own, an AppImage is a file kept wherever its owner
keeps it, and a checkout is updated with git. Being wrong about any one of
them means an installation somebody has to repair by hand.

Versions are compared by their numbers alone. A build off master carries the
released number with its commit after it, and that build is ahead of the
release it names rather than behind it; read as a version suffix, every
nightly would be told to go back to a release it had already passed.

The clock lives in its own file rather than in the settings, since a check
runs while the settings window may be open and a background write into
config.json is what would overwrite whatever it holds.
2026-08-22 10:34:59 +03:00
yusufipek f55e201785 Cut a long file into chunks a hosted request can outlive
An hour and a half of speech came back as "OpenRouter: HTTP 502: The
operation was aborted due to timeout", every time. The upload limit was
the only thing deciding where the file was cut, and mp3 at 48 kbps
reaches 24 MB after an hour, so a 90 minute file became two chunks and
the first one was 63 minutes of audio in a single request. Nothing
between here and the model stays on the line that long.

A chunk is capped at fifteen minutes now, whatever it weighs, and the
call is finally handed a timeout of its own: it was going out on the 300
second default sized for a dictation, which the same chunk would have
hit first anyway.

The other half is not throwing the run away when one request fails.
ApiError carries whether a second try can fix it, which is true of the
statuses a gateway raises itself and of a dropped connection, and false
of a rejected key. A chunk is asked for three times, waiting five then
ten seconds, with the Stop button still able to get through. If it does
fail in the end, what was already heard goes to the output box rather
than the bin, with the status line saying where it stops.
2026-08-20 12:27:42 +03:00
yusufipek 29643f26b3 Keep the two Windows executables from being one file
Windows matches a filename without regard to its case, so Dikte.exe and
dikte.exe were one file in the installation directory: PyInstaller wrote
the console build second and that is the only one that landed. Every
launcher then opened a console window behind the tray, and closing it
killed the application.

The console one is dikte-cli.exe now, which the setup's shim names. The
build reads the PE subsystem of both afterwards, since a filesystem that
keeps the two names apart is exactly what the check would run on
otherwise.
2026-08-19 11:11:30 +03:00
firat 0be64c2ef9 Wait for macOS focus restoration to land 2026-08-18 18:26:15 +02:00
yusufipek 23d56acfb5 Publish a Windows setup beside the AppImage and the disk image
The releases page had nothing for Windows, so the only way in was a
checkout, a Python and a pip install. What goes out now is one setup
program per release: PyInstaller's directory, the pinned ffmpeg the
disk image already uses, and Inno Setup around both. It installs for
the account alone, so no administrator is asked for.

Two executables over the one program there, because a windowed one on
Windows has no standard output at all: Dikte.exe for the Start Menu and
dikte.exe for the terminal, sharing everything they carry. The icon is
drawn by Dikte itself into an .ico, the way the Mac's .icns and Linux's
PNGs already are, so there is still no image file in the repository.

Starting at sign-in is a registry value rather than a Startup shortcut,
which is what lets the setup program, the uninstaller and
`dikte integrate` all mean the same thing: the wizard asks once, and
typing the command changes the answer later.

The three builds move into build.yml, which release.yml now calls
instead of holding its own copy, and which a pull request touching the
packaging runs on its own. A broken build is then a red pull request
rather than a failed release.
2026-08-18 16:08:47 +03:00
Yusuf İpek d24da7df52 Merge pull request #44 from benfirad/agent/remove-integration-wrapper
Remove macOS command wrapper during integration cleanup
2026-08-18 15:40:47 +03:00
huseyin-emre-tigciandClaude Fable 5 11a4058e16 Let the device listing match ffmpeg 8's bracket prefix
The dshow parser pinned the bracketed prefix to the `[dshow @ ...]` the
older builds print, and ffmpeg 8 writes `[in#0 @ ...]` there instead: on a
current install every line fell through the anchors, the device list came
back empty, and a dictation with no microphone picked could not start at
all. The prefix is now any bracketed tag, which the anchors and the quoted
name still keep apart from the error lines. Pinned with a listing taken
verbatim from ffmpeg 8.1.2 on Windows 11, third kind marker `(none)`
included.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-17 01:02:42 +03:00
firat 45e7af474a Remove downloaded Mac command during integration cleanup 2026-08-16 17:18:46 +02:00
GökhanandClaude Opus 5 45b18722be Keep the front where the dictation started on macOS
Recording goes through ffmpeg's avfoundation input, and opening a capture
session there brings the process that did it to the front. ffmpeg is a
child of Dikte with no bundle of its own, so macOS credits the move to
Dikte: the window the user was dictating into goes inactive, its caret
stops, its title bar greys out, and the Cmd+V at the end of the run lands
somewhere other than the document it was meant for. Measured with a
TextEdit document in front:

    press the shortcut          front = TextEdit
    recorder.start returns      front = TextEdit
    89 ms later                 front = Dikte

Nothing about the capture session can be asked not to do this. Starting
ffmpeg in its own session, and clearing __CFBundleIdentifier from its
environment, were both tried and both measured to make no difference, so
it is undone instead: the application in front is noted before the
indicator goes up, and a short watch puts it back the moment Dikte takes
the front. Measured at 99 ms from the moment it is taken, against the
title bar staying grey for the whole dictation before. All three ways in
do it, dictation, agent and meeting, since all three open the same
capture.

The indicator had a share of the same problem and needed AppKit for it
too, so mac_window.py carries both. An NSPanel is hidden by the system
the moment its application stops being the active one, which for a
dictation indicator is immediately, and ordering one to the front brings
its application with it unless it carries the nonactivating style bit.
Neither is reachable through Qt.

The runtime is loaded in _appkit() rather than at import, the way paste.py
loads its frameworks in _macos_api(), so the module imports on a machine
with no AppKit and the tests below run there as well: 52 new tests, none
of them skipped anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-16 17:37:31 +03:00
yusufipek 180ddac37e Let the Windows job read the merged suite
The merge put a Windows runner under tests master wrote for two systems,
and 21 of them fell over on it. Two were the quoting: `command_for` goes
through shlex now, so a Windows path comes back in quotes, and the two
tests that read the command as a string were reading it as a Linux one.
They ask through the same join instead.

The other nineteen are `integrate.py`, which writes the menu entry and
the login item a downloaded build installs for itself. There are two
downloads, an AppImage and a disk image, so the module has a Linux half
and a macOS half and nothing a Windows host would run: its tests hand
the fake home over in $HOME, which Windows does not read, and compare
paths that start at the root. They carry `@posix_only`, which comes off
again the day there is a Windows build to integrate.
2026-08-16 15:54:44 +03:00
yusufipek 80ff4ecade Merge master: the downloadable builds
Three files disagreed. The test workflow gained a job on either side, so
both stay: the Mac now parses the release and packaging scripts as well,
and Windows keeps its own job below.

`restart` and `launch_gui` both start Dikte again, and master moved that
argv into `ipc.launcher()` because a packaged build has no `__main__.py`
to name. Windows still cannot use execv there, so the detached start it
needs now takes what the launcher hands it rather than spelling the
interpreter and the script itself.

The test counts in CONTRIBUTING are the suite as it stands after the
merge: 1104 of 1147 run anywhere, and the 43 left are the Linux ones.
2026-08-16 15:44:40 +03:00
yusufipek 4cec05fa49 Look up the machine's certificates rather than the build machine's
A build carries the OpenSSL of the machine it was built on, and that
OpenSSL has one directory compiled into it as the only place it looks for
certificates. For an AppImage built on Ubuntu that is /usr/lib/ssl, which
Arch, Fedora and openSUSE do not have, so on any of them every HTTPS
request fails with CERTIFICATE_VERIFY_FAILED: transcription and cleanup
report it as a rejected key, and the model downloads fail too.

Ask the machine instead, from the list curl and Go use, and only when the
build's own answer turns out not to exist. The store on the machine
rather than a copy carried along, because a copy goes stale as roots are
rotated and would ignore a certificate somebody added themselves. Anybody
who has already set SSL_CERT_FILE is left alone.
2026-08-16 15:24:33 +03:00
yusufipek 01c91eb425 Resolve the test's home before comparing paths to it
A Mac keeps temporary directories under /var, which is a symlink to
/private/var, and target() resolves what it is handed because a bundle
reached through a symlink is still that bundle. So the login item named
/private/var/... while the test held /var/..., and the two spellings of
one path did not match.
2026-08-16 15:09:40 +03:00
yusufipek 7e510f8b35 Build an AppImage and a disk image, and publish them
Installing meant cloning the repository and running a shell script, which
is a fair ask of somebody who already has a terminal open and no ask at
all of anybody else. The releases page now carries an AppImage and a disk
image per Mac architecture: a push to master rebuilds a rolling "latest",
a v* tag publishes a version and leaves it there, and the Run button in
the Actions tab raises the number by running scripts/release.sh, which is
the same script and not a second copy of what it does.

Two things in the application had to give for that. A build has no
__main__.py on disk, and an AppImage is mounted somewhere new every run,
so the command a shortcut is registered with cannot go on being this
interpreter and this file; ipc.launcher() answers with the AppImage or
the bundle instead. And a build carries its own libstdc++, which every
process it starts inherits through LD_LIBRARY_PATH and none of them can
live with: ffmpeg, ydotool and wl-copy are the distribution's binaries
built against the distribution's libraries, and AppImageLauncher, which
is what starting the AppImage again goes through, refuses outright.
integrate.py puts that variable back before anything else runs.

Nothing installs itself over an installation that is already there.
install.sh's menu entry, install-mac.sh's login item and the desktop file
AppImageLauncher writes are each recognised and left alone, so trying a
download once does not quietly move the machine onto it. `dikte
integrate` is how you ask for it outright, and --remove takes it back.

The disk image carries an ffmpeg, pinned and checksummed, because macOS
records through one and ships nothing like it. It is signed ad-hoc and
not with an Apple certificate, so a first launch is refused until Open
Anyway and the permissions are asked for again after each update; both
READMEs and the release notes say so.
2026-08-16 15:03:44 +03:00
yusufipek bac988e0e6 Ask for the entry point by its parts, not by a slash
The assertion spelled the path with a forward slash, which is not the separator
Windows joins with. The Windows job this branch adds is the first to run it
there.
2026-08-16 14:13:12 +03:00
yusufipek b186f7fde2 Merge master: the modules moved into a package
Every file this branch touches moved into dikte/, so the merge is mostly the
rename following the edits. What needed a hand:

hotkey.py: master replaced the _macos()/_gnome() pair with one backend()
chooser, and this branch had added _windows() to the pair. Windows is a fifth
value of the chooser now, and everything that used to ask "macOS or Windows?"
asks backend() instead. The key is held by the running process there, so
installs_shortcuts() and shortcut_needs_restart() are both false for it, and
desktop_name() says Windows.

install.ps1 and the Windows README name dikte/__main__.py, the entry point the
Linux and macOS installers were pointed at in the same commit. The Start Menu
entry, the autostart entry and the dikte.cmd shim all come off one $entry
variable.

settings_ui.py: the shortcut tab now has a Windows sentence of its own, with
the Turkish for it. Falling through to the branch master wrote for a desktop
with no registry would have told a Windows user to check /dev/input. Nothing
covers that branch: there is no Windows Settings test class, the way there is
one for macOS.

CONTRIBUTING: the chooser it names is backend() now, and the test count is the
merged one, 1067 of 1110 running anywhere.
2026-08-16 14:10:28 +03:00
yusufipek 2f59029261 Put the modules in a package and the scripts in a folder
Twenty-two files at the top of the tree was the first thing anybody saw of
this repository. They are one package now, imported relatively, and the three
scripts that are not the front door moved under scripts/. install.sh stays
where the README has always said it is.

What starts the application is dikte/__main__.py: python3 -m dikte runs it,
and so does naming the file, which is what the launcher symlink, both .desktop
files, the macOS bundle and every registered shortcut do. Run by path there is
no package around it, so it puts the checkout on sys.path itself.

The installers now keep the keys you chose when they are given none, which is
what an update is: update.sh no longer has to read them out and pass them back.
An updater from before this commit cannot read them at all, so the one thing it
can say, the default key with an empty discard key, is read as "nothing was
asked for" rather than obeyed. That guard can go once nobody is updating across
this commit.
2026-08-16 14:01:01 +03:00
Yusuf İpek 8444b6822d Merge pull request #36 from yusufipk/generic-desktop-shortcuts
Stop telling every desktop that it is KDE
2026-08-16 13:43:38 +03:00
yusufipek 1f5ea70fa6 Stop telling every desktop that it is KDE
Only GNOME was recognised, and everything else was handed to KWin. On i3,
XFCE, Cinnamon, MATE, sway and the rest, Dikte wrote an entry into
kglobalshortcutsrc that nothing reads, called the session KDE, and
promised that the keys would work after the next login. They never did.

There is no backend to write for any of them. The /dev/input listener is
already desktop-agnostic, so those sessions are the case macOS has always
been: no registry, nothing to install, nothing to remove, and the
combination held by the running process. One backend() function decides
which of the four this session has, and the name shown, the status read
back, what Install writes, what Settings explains and what the installer
promises are all taken from it, so they cannot disagree.

A desktop now only counts when the program that writes its registry is
there too. A GNOME session without gsettings and a Plasma one without
kwriteconfig6 fall to the listener rather than to a file, which is also
how Plasma 5 stops erroring on a kwriteconfig6 it never had.

What Settings shows on those desktops is the truth: no Install button, no
KWin, no listener checkbox (it is the mechanism, not a choice), what
reading /dev/input costs, that the focused application sees the keys too,
and the command to bind if you would rather your desktop owned them. The
evdev listener records what it is listening for the way the Carbon one
does, so the status line has something to say there at all.

Closes #28
2026-08-16 13:41:27 +03:00
yusufipek e40191e3a7 Give the tray an icon a black bar cannot swallow
A session that names no desktop, which is what i3 and a bare X11 login are,
leaves Qt with hicolor as its only icon theme, and hicolor has none of the four
names the tray asks for. So fromTheme returns nothing and the shapes drawn in
trayicon.py are used, as they are on macOS. They were drawn in black, which
macOS recolours through the mask and X11 does not, and i3's bar is black: the
icon was there all along, painted onto a bar of its own colour. Outside macOS
they are now white over a dark copy of themselves spread a pixel out, which
stands out on a dark bar and stays readable on a light one, and the mask is set
only where something reads it.

The .desktop files had the same hole from the other side. They named
audio-input-microphone, which is in Breeze and in Adwaita but not in hicolor, so
the menu entry and the autostart entry were blank on the same systems. install.sh
now draws the application icon into ~/.local/share/icons/hicolor and both entries
name it; uninstall.sh takes it back. The windows carry it too on X11, where a
window has no .desktop file to be looked up in.

Closes #27
2026-08-16 13:28:57 +03:00