Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef8873977c | ||
|
|
60990276f5 | ||
|
|
97cc446c15 | ||
|
|
ac2067dd58 | ||
|
|
8d01097ca8 |
@@ -190,14 +190,18 @@ const (
|
||||
// Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so
|
||||
// global (not per-profile) like CAT/rotator. Device fields store the
|
||||
// WASAPI endpoint id; the UI resolves it to a friendly name.
|
||||
keyAudioFromRadio = "audio.from_radio" // capture: rig RX audio in
|
||||
keyAudioToRadio = "audio.to_radio" // render: DVK plays into rig
|
||||
keyAudioRecDevice = "audio.rec_device" // capture: your mic (record DVK msgs)
|
||||
keyAudioListenDevice = "audio.listen_device" // render: local preview speakers
|
||||
keyAudioQSORecord = "audio.qso_record" // "1" → auto-record every QSO
|
||||
keyAudioQSODir = "audio.qso_dir" // folder for QSO recordings
|
||||
keyAudioPreroll = "audio.preroll_seconds" // rolling-buffer pre-roll length
|
||||
keyAudioTXGain = "audio.tx_gain" // voice-keyer playback level % (100 = as recorded)
|
||||
keyAudioFromRadio = "audio.from_radio" // capture: rig RX audio in
|
||||
keyAudioToRadio = "audio.to_radio" // render: DVK plays into rig
|
||||
keyAudioRecDevice = "audio.rec_device" // capture: your mic (record DVK msgs)
|
||||
keyAudioListenDevice = "audio.listen_device" // render: local preview speakers
|
||||
// keyAudioDigiInput: capture device carrying a DIGITAL program's transmit
|
||||
// audio — WSJT-X and the like — for a radio whose audio rides its network
|
||||
// link. See app_digi_txaudio.go.
|
||||
keyAudioDigiInput = "audio.digi_input"
|
||||
keyAudioQSORecord = "audio.qso_record" // "1" → auto-record every QSO
|
||||
keyAudioQSODir = "audio.qso_dir" // folder for QSO recordings
|
||||
keyAudioPreroll = "audio.preroll_seconds" // rolling-buffer pre-roll length
|
||||
keyAudioTXGain = "audio.tx_gain" // voice-keyer playback level % (100 = as recorded)
|
||||
// Replaying a QSO recording needs its OWN level, and by a wide margin.
|
||||
//
|
||||
// A voice-keyer message is a microphone at speaking distance; a QSO
|
||||
@@ -918,6 +922,10 @@ type App struct {
|
||||
// pass. nil when nothing is being tracked.
|
||||
satTrackMu sync.Mutex
|
||||
satTrack *satTracker
|
||||
// digiTX: WE started the digital transmit-audio route, so WE may stop it.
|
||||
// The talk button and the voice keyer share that route, and unkeying a
|
||||
// WSJT-X over must not cut off a message the operator is playing by hand.
|
||||
digiTX atomic.Bool
|
||||
|
||||
cwMu sync.Mutex // guards the CW decoder lifecycle
|
||||
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
|
||||
@@ -8856,6 +8864,11 @@ type AudioSettings struct {
|
||||
FromRadio string `json:"from_radio"` // capture id: rig RX audio
|
||||
ToRadio string `json:"to_radio"` // render id: into the rig
|
||||
RecordingDevice string `json:"recording_device"` // capture id: your mic
|
||||
// DigiInput is where WSJT-X (or any digital program) puts its transmit
|
||||
// audio, when the radio takes audio over its network link and there is no
|
||||
// sound card between the two. A virtual cable's OUTPUT side. Empty turns the
|
||||
// whole path off. See app_digi_txaudio.go.
|
||||
DigiInput string `json:"digi_input"`
|
||||
ListeningDevice string `json:"listening_device"` // render id: preview
|
||||
QSORecord bool `json:"qso_record"` // auto-record every QSO
|
||||
QSODir string `json:"qso_dir"` // recordings folder
|
||||
@@ -8926,7 +8939,7 @@ func (a *App) GetAudioSettings() (AudioSettings, error) {
|
||||
return out, nil
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
|
||||
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice, keyAudioDigiInput,
|
||||
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioPTTData, keyAudioFormat,
|
||||
keyAudioFromGain, keyAudioMicGain, keyAudioTXGain, keyAudioQSOPlayGain)
|
||||
if err != nil {
|
||||
@@ -8943,6 +8956,7 @@ func (a *App) GetAudioSettings() (AudioSettings, error) {
|
||||
out.FromRadio = m[keyAudioFromRadio]
|
||||
out.ToRadio = m[keyAudioToRadio]
|
||||
out.RecordingDevice = m[keyAudioRecDevice]
|
||||
out.DigiInput = m[keyAudioDigiInput]
|
||||
out.ListeningDevice = m[keyAudioListenDevice]
|
||||
out.QSORecord = m[keyAudioQSORecord] == "1"
|
||||
out.QSODir = m[keyAudioQSODir]
|
||||
@@ -9004,6 +9018,7 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
|
||||
keyAudioFromRadio: s.FromRadio,
|
||||
keyAudioToRadio: s.ToRadio,
|
||||
keyAudioRecDevice: s.RecordingDevice,
|
||||
keyAudioDigiInput: strings.TrimSpace(s.DigiInput),
|
||||
keyAudioListenDevice: s.ListeningDevice,
|
||||
keyAudioQSORecord: qr,
|
||||
keyAudioQSODir: strings.TrimSpace(s.QSODir),
|
||||
@@ -22108,7 +22123,27 @@ func (r catShareRig) RxFreq() int64 {
|
||||
|
||||
func (r catShareRig) SetFreq(hz int64) error { return r.a.cat.SetFrequency(hz) }
|
||||
func (r catShareRig) SetMode(m string) error { return r.a.cat.SetMode(m) }
|
||||
func (r catShareRig) SetPTT(on bool) error { return r.a.cat.SetPTT(on) }
|
||||
|
||||
// SetPTT keys the rig for a sharing client — and carries that client's
|
||||
// transmit AUDIO with it when the radio takes audio over its network link.
|
||||
//
|
||||
// WSJT-X drives an Icom-over-Ethernet through this server: it has the CAT, so
|
||||
// it can key and tune, but it has no sound card to reach a radio that has
|
||||
// none either. The audio route below is the missing half — see
|
||||
// app_digi_txaudio.go for why it hangs off PTT rather than being left running.
|
||||
func (r catShareRig) SetPTT(on bool) error {
|
||||
if !on {
|
||||
// Audio down BEFORE the carrier: the other order transmits whatever is
|
||||
// still in the buffer after the program thinks the over is finished.
|
||||
r.a.stopDigiTXAudio()
|
||||
return r.a.cat.SetPTT(false)
|
||||
}
|
||||
if err := r.a.cat.SetPTT(true); err != nil {
|
||||
return err // no point streaming into a rig that is not transmitting
|
||||
}
|
||||
r.a.startDigiTXAudio()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSplit passes the client's split request through to the radio, and passes
|
||||
// the refusal back when the backend cannot do it. That refusal is the feature:
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package main
|
||||
|
||||
// ── Transmit audio for a digital program, over the radio's own link ────────
|
||||
//
|
||||
// An IC-705, IC-7610 or IC-7760 reached over Ethernet has no sound card on this
|
||||
// PC: its receive audio arrives on UDP 50003 and its transmit audio has to go
|
||||
// back the same way. OpsLog already does both — the RX monitor plays the stream,
|
||||
// the voice keyer and the talk button send into it.
|
||||
//
|
||||
// WSJT-X cannot. It drives the rig through the CAT OpsLog shares (rigctld), so
|
||||
// it can tune and key perfectly well, and then has nowhere to put its audio: it
|
||||
// needs a Windows audio endpoint and the radio is not one. A virtual cable
|
||||
// bridges the gap — WSJT-X's output device is the cable, and OpsLog captures the
|
||||
// cable's other side and streams it to the rig. That is what wfview asks of its
|
||||
// users too; nothing short of a signed kernel driver can present a sound card,
|
||||
// and OpsLog is neither signed nor a driver.
|
||||
//
|
||||
// The route is armed by PTT rather than left running. Streaming continuously
|
||||
// would put whatever the cable carries — the desktop's notification sounds, an
|
||||
// idle WSJT-X's silence, another program that grabbed the same cable — into a
|
||||
// transmitter the moment anything else keyed it. PTT is also exactly the signal
|
||||
// available: WSJT-X keys through us, so we know.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/audio"
|
||||
"hamlog/internal/cat"
|
||||
)
|
||||
|
||||
// digiTXSender returns the radio's live-audio sender, or nil when this station
|
||||
// is not set up for the path at all.
|
||||
//
|
||||
// Three things have to be true, and none of them is an error worth reporting on
|
||||
// every over: a capture device is configured, the radio takes audio over its
|
||||
// link, and its audio session is actually open.
|
||||
func (a *App) digiTXSender() (dev string, send func([]byte) error) {
|
||||
if a.audioMgr == nil || a.cat == nil {
|
||||
return "", nil
|
||||
}
|
||||
cfg, err := a.GetAudioSettings()
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
dev = strings.TrimSpace(cfg.DigiInput)
|
||||
if dev == "" {
|
||||
return "", nil
|
||||
}
|
||||
type sender interface {
|
||||
TXAudioSender() (func([]byte) error, error)
|
||||
}
|
||||
if derr := a.cat.IcomDo(func(ic cat.IcomController) error {
|
||||
p, ok := ic.(sender)
|
||||
if !ok {
|
||||
return nil // not a radio that takes audio over its link
|
||||
}
|
||||
fn, serr := p.TXAudioSender()
|
||||
if serr != nil {
|
||||
// The stream is not open — RX audio is switched off in Settings ▸ CAT.
|
||||
// Said once per over at most, and it names the fix.
|
||||
applog.Printf("digi audio: %v", serr)
|
||||
return nil
|
||||
}
|
||||
send = fn
|
||||
return nil
|
||||
}); derr != nil {
|
||||
return "", nil
|
||||
}
|
||||
if send == nil {
|
||||
return "", nil
|
||||
}
|
||||
return dev, send
|
||||
}
|
||||
|
||||
// startDigiTXAudio begins piping the configured capture device into the radio.
|
||||
//
|
||||
// Called after the rig is keyed, so a failure here leaves a transmitting radio
|
||||
// with no audio rather than audio going into a receiver — and it is reported
|
||||
// rather than silently dropped, because a station that transmits silence for a
|
||||
// whole evening has no other way to find out.
|
||||
func (a *App) startDigiTXAudio() {
|
||||
dev, send := a.digiTXSender()
|
||||
if send == nil {
|
||||
return
|
||||
}
|
||||
if err := a.audioMgr.StartTXAudioNetwork(dev, send); err != nil {
|
||||
applog.Printf("digi audio: %q could not be opened, the over will be silent: %v", dev, err)
|
||||
return
|
||||
}
|
||||
a.digiTX.Store(true)
|
||||
applog.Printf("digi audio: %q → the radio, for as long as the shared CAT holds PTT", dev)
|
||||
}
|
||||
|
||||
// stopDigiTXAudio ends the route. Only when WE started it: the talk button and
|
||||
// the voice keyer use the same one, and unkeying a digital over must not cut
|
||||
// off a message the operator is playing by hand.
|
||||
func (a *App) stopDigiTXAudio() {
|
||||
if !a.digiTX.Swap(false) {
|
||||
return
|
||||
}
|
||||
if a.audioMgr != nil {
|
||||
a.audioMgr.StopTXAudio()
|
||||
}
|
||||
}
|
||||
|
||||
// ListDigiInputDevices is the dropdown behind the setting: the capture devices,
|
||||
// which for this purpose means the virtual cable WSJT-X plays into.
|
||||
//
|
||||
// The radio itself is deliberately NOT offered. It is a source of receive audio,
|
||||
// and picking it here would ask OpsLog to send the radio its own output.
|
||||
func (a *App) ListDigiInputDevices() ([]audio.Device, error) {
|
||||
return audio.ListInputDevices()
|
||||
}
|
||||
@@ -1,4 +1,15 @@
|
||||
[
|
||||
{
|
||||
"version": "0.27.25",
|
||||
"en": [
|
||||
"The relaunch after an update goes back to the helper that waited for OpsLog to close before starting the new version. Two rewrites tried to do without it and both left some operators with no window. Windows Defender may flag it — allow OpsLog in Defender if it does.",
|
||||
"WSJT-X can now transmit through an Icom reached over Ethernet. Set its output to a virtual audio cable and choose that cable under Settings ▸ Audio — OpsLog streams it to the radio while WSJT-X holds PTT through the shared CAT."
|
||||
],
|
||||
"fr": [
|
||||
"La relance après une mise à jour revient à l’assistant qui attendait la fermeture d’OpsLog avant de démarrer la nouvelle version. Deux réécritures ont essayé de s’en passer et laissaient certains opérateurs sans fenêtre. Windows Defender peut le signaler — dans ce cas, autorisez OpsLog dans Defender.",
|
||||
"WSJT-X peut désormais émettre à travers un Icom joint en Ethernet. Réglez sa sortie sur un câble audio virtuel et choisissez ce câble dans Réglages ▸ Audio — OpsLog le diffuse vers la radio pendant que WSJT-X tient le PTT via le CAT partagé."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.24",
|
||||
"en": [
|
||||
|
||||
@@ -1984,14 +1984,14 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
|
||||
// ── Audio (DVK + QSO recorder) ──
|
||||
type AudioSettings = {
|
||||
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
|
||||
from_radio: string; to_radio: string; recording_device: string; listening_device: string; digi_input: string;
|
||||
qso_record: boolean; qso_dir: string; preroll_seconds: number;
|
||||
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; ptt_data?: boolean; format: 'wav' | 'mp3';
|
||||
from_gain: number; mic_gain: number; tx_gain: number; qso_play_gain: number;
|
||||
};
|
||||
type AudioDev = { id: string; name: string; default: boolean };
|
||||
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
|
||||
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
|
||||
from_radio: '', to_radio: '', recording_device: '', listening_device: '', digi_input: '',
|
||||
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', ptt_data: false, format: 'wav',
|
||||
from_gain: 100, mic_gain: 100, tx_gain: 100, qso_play_gain: 100,
|
||||
});
|
||||
@@ -7784,6 +7784,9 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
const NET_DEVICE = 'net:radio';
|
||||
const soundCardsOnly = (devs: AudioDev[]) => devs.filter((d) => d.id !== NET_DEVICE);
|
||||
const fromRadioIsNetwork = audioCfg.from_radio === NET_DEVICE;
|
||||
// The radio takes transmit audio over its link, so a digital program can
|
||||
// reach it through a cable OpsLog reads.
|
||||
const toRadioIsNetwork = audioCfg.to_radio === NET_DEVICE;
|
||||
|
||||
const deviceSelect = (
|
||||
field: keyof AudioSettings,
|
||||
@@ -7829,7 +7832,22 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
{deviceSelect('recording_device', soundCardsOnly(audioInputs), t('aud.phRecMic'))}
|
||||
<Label className="text-sm">{t('aud.listening')}</Label>
|
||||
{deviceSelect('listening_device', soundCardsOnly(audioOutputs), t('aud.phListening'))}
|
||||
{/* Only for a radio that carries its own audio, which is the only
|
||||
case where a digital program has nowhere to put its transmit
|
||||
audio: the rig has no sound card on this PC, so a virtual
|
||||
cable stands in and OpsLog captures its other side. Hidden
|
||||
otherwise — a rig with a USB codec needs none of this, and
|
||||
WSJT-X talks to that codec directly. */}
|
||||
{toRadioIsNetwork && (
|
||||
<>
|
||||
<Label className="text-sm">{t('aud.digiInput')}</Label>
|
||||
{deviceSelect('digi_input', soundCardsOnly(audioInputs), t('aud.phDigiInput'))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{toRadioIsNetwork && (
|
||||
<p className="text-[11px] text-muted-foreground max-w-2xl">{t('aud.digiInputHint')}</p>
|
||||
)}
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<strong>{t('aud.fromRadioShort')}</strong> {t('aud.explainFrom')}{' '}
|
||||
<strong>{t('aud.toRadioShort')}</strong> {t('aud.explainTo')}
|
||||
|
||||
@@ -577,7 +577,9 @@ const en: Dict = {
|
||||
'clg2.allFiltered': '{n} spots received, none shown — your filters are hiding them all.', 'clg2.activeFilters': 'Active:', 'clg2.clearAllFilters': 'Clear every filter', 'clg2.fBandLock': 'band locked to the rig', 'clg2.fBands': 'bands {list}', 'clg2.fModeLock': 'mode locked to the rig', 'clg2.fModes': 'modes {list}', 'clg2.fStatus': 'status chips', 'clg2.fHideWorked': 'hide worked', 'clg2.fLotwOnly': 'LoTW users only', 'clg2.fSpotterCont': 'spotter continent', 'clg2.fSource': 'one source node', 'clg2.fSearch': 'search “{q}”',
|
||||
'clg2.c.time': 'Time', 'clg2.c.call': 'Call', 'clg2.c.status': 'Status', 'clg2.c.pota': 'POTA', 'clg2.c.sota': 'SOTA', 'clg2.c.freq': 'Freq', 'clg2.c.band': 'Band', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Pfx', 'clg2.c.cqz': 'CQ Zone', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'ITU Zone', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Short path (°)', 'clg2.h.sp_deg': 'SP°', 'clg2.c.lp_deg': 'Long path (°)', 'clg2.h.lp_deg': 'LP°', 'clg2.c.country': 'Country', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Spotter locator', 'clg2.h.locator': 'Spotter loc', 'clg2.c.county': 'US County', 'clg2.tipNewCounty': 'NEW COUNTY — never worked', 'clg2.tipNewPfx': 'NEW PREFIX — this WPX prefix has never been worked', 'clg2.c.comment': 'Comment', 'clg2.c.received_at': 'Received at', 'clg2.h.received_at': 'Received UTC', 'clg2.c.raw': 'Raw', 'clg2.newDxcc': 'NEW DXCC', 'clg2.newBandMode': 'NEW B+M', 'clg2.newBand': 'NEW BAND', 'clg2.newMode': 'NEW MODE', 'clg2.newSlot': 'NEW SLOT', 'clg2.newCall': 'NEW CALL', 'clg2.wkdCall': 'WKD CALL', 'clg2.newCounty': 'NEW CTY', 'clg2.newGridUnconf': 'GRID?', 'clg2.newState': 'New State', 'clg2.newGrid': 'NEW GRID', 'clg2.c.grid': 'Grid', 'clg2.tipNewGrid': 'NEW GRID — this square has never been worked (grid heard in a CQ on the UDP link)', 'clg2.newPfx': "NEW PFX", 'clg2.newPota': 'NEW POTA', 'clg2.tipNewDxcc': 'NEW DXCC: {country}', 'clg2.tipWorkedCall': 'Already worked this call', 'clg2.tipNewBandMode': 'NEW BAND AND NEW MODE for this entity — neither has been worked with it', 'clg2.tipNewBand': 'NEW BAND for this entity', 'clg2.tipNewSlotBand': 'NEW SLOT (mode not yet worked on this band)', 'clg2.tipNewMode': 'NEW MODE (this mode never worked on this entity)', 'clg2.tipNewSlot': 'NEW SLOT (this band+mode not yet worked)', 'clg2.tipNewCall': 'NEW CALL — this callsign has never been worked on this band and mode (the entity has)', 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Geo', 'clg2.clearFiltersTitle': 'Clear all column filters', 'clg2.clearFilters': 'Clear filters', 'clg2.newSpots': '{n} new spots — click to resume', 'clg2.columns': 'Columns', 'clg2.pickerTitle': 'Cluster columns', 'clg2.pickerDesc': 'Pick the columns you want visible in the Cluster table.', 'clg2.allGroups': 'All groups:', 'clg2.all': 'all', 'clg2.none': 'none', 'clg2.resetDefaults': 'Reset to defaults', 'clg2.done': 'Done',
|
||||
// Audio devices & voice keyer (Preferences → Audio devices).
|
||||
'aud.refreshDevices': 'Refresh devices', 'aud.fromRadio': 'From Radio (RX in)', 'aud.toRadio': 'To Radio (TX out)', 'aud.recMic': 'Recording mic', 'aud.listening': 'Listening (preview)',
|
||||
'aud.refreshDevices': 'Refresh devices',
|
||||
'aud.digiInput': 'WSJT-X transmit audio', 'aud.phDigiInput': 'The virtual cable WSJT-X plays into',
|
||||
'aud.digiInputHint': "Your radio has no sound card on this PC, so a digital program cannot reach it. Install a virtual audio cable, set WSJT-X's output to it, and choose the same cable's recording side here — OpsLog streams it to the radio while WSJT-X holds PTT through the shared CAT. For receive, point WSJT-X's input at a second cable and set Listening to it.", 'aud.fromRadio': 'From Radio (RX in)', 'aud.toRadio': 'To Radio (TX out)', 'aud.recMic': 'Recording mic', 'aud.listening': 'Listening (preview)',
|
||||
'aud.phFromRadio': 'Rig audio output → soundcard input', 'aud.phToRadio': 'Soundcard output → rig mic/data in', 'aud.phRecMic': 'Your microphone (record voice-keyer messages)', 'aud.phListening': 'Local speakers for preview',
|
||||
'aud.noneDefault': '— none / system default —', 'aud.defaultTag': '(default)',
|
||||
'aud.fromRadioShort': 'From Radio', 'aud.toRadioShort': 'To Radio', 'aud.explainFrom': '= what you receive (used by the QSO recorder).', 'aud.explainTo': '= where voice-keyer messages are transmitted.',
|
||||
@@ -1205,7 +1207,9 @@ const fr: Dict = {
|
||||
'clg2.allFiltered': '{n} spots reçus, aucun affiché — vos filtres les masquent tous.', 'clg2.activeFilters': 'Actifs :', 'clg2.clearAllFilters': 'Effacer tous les filtres', 'clg2.fBandLock': 'bande verrouillée sur la radio', 'clg2.fBands': 'bandes {list}', 'clg2.fModeLock': 'mode verrouillé sur la radio', 'clg2.fModes': 'modes {list}', 'clg2.fStatus': 'pastilles de statut', 'clg2.fHideWorked': 'masquer les contactés', 'clg2.fLotwOnly': 'utilisateurs LoTW uniquement', 'clg2.fSpotterCont': 'continent du spotteur', 'clg2.fSource': 'un seul nœud source', 'clg2.fSearch': 'recherche « {q} »',
|
||||
'clg2.c.time': 'Heure', 'clg2.c.call': 'Indicatif', 'clg2.c.status': 'Statut', 'clg2.c.pota': 'POTA', 'clg2.c.sota': 'SOTA', 'clg2.c.freq': 'Fréq', 'clg2.c.band': 'Bande', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Préf.', 'clg2.c.cqz': 'Zone CQ', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'Zone ITU', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Chemin court (°)', 'clg2.h.sp_deg': 'CC°', 'clg2.c.lp_deg': 'Chemin long (°)', 'clg2.h.lp_deg': 'CL°', 'clg2.c.country': 'Pays', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Locator du spotter', 'clg2.h.locator': 'Loc spotter', 'clg2.c.county': 'Comté US', 'clg2.tipNewCounty': 'NOUVEAU COMTÉ — jamais contacté', 'clg2.tipNewPfx': "NOUVEAU PRÉFIXE — ce préfixe WPX n'a jamais été contacté", 'clg2.c.comment': 'Commentaire', 'clg2.c.received_at': 'Reçu le', 'clg2.h.received_at': 'Reçu UTC', 'clg2.c.raw': 'Brut', 'clg2.newDxcc': 'NOUV DXCC', 'clg2.newBandMode': 'NOUV B+M', 'clg2.newBand': 'NOUV BANDE', 'clg2.newMode': 'NOUV MODE', 'clg2.newSlot': 'NOUV SLOT', 'clg2.newCall': 'CALL NEUF', 'clg2.wkdCall': 'DÉJÀ QSO', 'clg2.newCounty': 'NOUV CTY', 'clg2.newGridUnconf': 'GRID?', 'clg2.newState': 'Nouvel État', 'clg2.newGrid': 'NOUV GRID', 'clg2.c.grid': 'Grid', 'clg2.tipNewGrid': 'NOUVEAU GRID — ce carré n a jamais été contacté (grid entendu dans un CQ sur le lien UDP)', 'clg2.newPfx': "NOUVEAU PFX", 'clg2.newPota': 'NOUV POTA', 'clg2.tipNewDxcc': 'NOUVEAU DXCC : {country}', 'clg2.tipWorkedCall': 'Indicatif déjà contacté', 'clg2.tipNewBandMode': 'NOUVELLE BANDE ET NOUVEAU MODE pour cette entité — aucun des deux n’a été fait avec elle', 'clg2.tipNewBand': 'NOUVELLE BANDE pour cette entité', 'clg2.tipNewSlotBand': 'NOUVEAU SLOT (mode pas encore contacté sur cette bande)', 'clg2.tipNewMode': 'NOUVEAU MODE (ce mode jamais contacté sur cette entité)', 'clg2.tipNewSlot': 'NOUVEAU SLOT (cette bande+mode pas encore contactée)', 'clg2.tipNewCall': "CALL NEUF — cet indicatif n a jamais été contacté sur cette bande et ce mode (l entité, si)", 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Géo', 'clg2.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'clg2.clearFilters': 'Effacer les filtres', 'clg2.newSpots': '{n} nouveaux spots — cliquer pour reprendre', 'clg2.columns': 'Colonnes', 'clg2.pickerTitle': 'Colonnes du cluster', 'clg2.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau du cluster.', 'clg2.allGroups': 'Tous les groupes :', 'clg2.all': 'tout', 'clg2.none': 'aucun', 'clg2.resetDefaults': 'Réinitialiser', 'clg2.done': 'Terminé',
|
||||
// Périphériques audio et manipulateur vocal (Préférences → Périphériques audio).
|
||||
'aud.refreshDevices': 'Actualiser les périphériques', 'aud.fromRadio': 'Depuis la radio (entrée RX)', 'aud.toRadio': 'Vers la radio (sortie TX)', 'aud.recMic': "Micro d'enregistrement", 'aud.listening': 'Écoute (pré-écoute)',
|
||||
'aud.refreshDevices': 'Actualiser les périphériques',
|
||||
'aud.digiInput': 'Audio d’émission WSJT-X', 'aud.phDigiInput': 'Le câble virtuel dans lequel WSJT-X émet',
|
||||
'aud.digiInputHint': "Votre radio n'a pas de carte son sur ce PC, un logiciel numérique ne peut donc pas l'atteindre. Installez un câble audio virtuel, réglez la sortie de WSJT-X dessus, et choisissez ici le côté enregistrement du même câble — OpsLog le diffuse vers la radio pendant que WSJT-X tient le PTT via le CAT partagé. Pour la réception, pointez l'entrée de WSJT-X sur un second câble et réglez « Écoute » dessus.", 'aud.fromRadio': 'Depuis la radio (entrée RX)', 'aud.toRadio': 'Vers la radio (sortie TX)', 'aud.recMic': "Micro d'enregistrement", 'aud.listening': 'Écoute (pré-écoute)',
|
||||
'aud.phFromRadio': 'Sortie audio du poste → entrée carte son', 'aud.phToRadio': 'Sortie carte son → entrée micro/data du poste', 'aud.phRecMic': 'Votre microphone (enregistrer les messages vocaux)', 'aud.phListening': 'Haut-parleurs locaux pour la pré-écoute',
|
||||
'aud.noneDefault': '— aucun / défaut système —', 'aud.defaultTag': '(défaut)',
|
||||
'aud.fromRadioShort': 'Depuis la radio', 'aud.toRadioShort': 'Vers la radio', 'aud.explainFrom': "= ce que vous recevez (utilisé par l'enregistreur de QSO).", 'aud.explainTo': '= où sont émis les messages du manipulateur vocal.',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.27.24';
|
||||
export const APP_VERSION = '0.27.25';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+2
@@ -834,6 +834,8 @@ export function ListCountries():Promise<Array<string>>;
|
||||
|
||||
export function ListDenkoviDevices():Promise<Array<string>>;
|
||||
|
||||
export function ListDigiInputDevices():Promise<Array<audio.Device>>;
|
||||
|
||||
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
||||
|
||||
export function ListProfiles():Promise<Array<profile.Profile>>;
|
||||
|
||||
@@ -1598,6 +1598,10 @@ export function ListDenkoviDevices() {
|
||||
return window['go']['main']['App']['ListDenkoviDevices']();
|
||||
}
|
||||
|
||||
export function ListDigiInputDevices() {
|
||||
return window['go']['main']['App']['ListDigiInputDevices']();
|
||||
}
|
||||
|
||||
export function ListOperatingTree() {
|
||||
return window['go']['main']['App']['ListOperatingTree']();
|
||||
}
|
||||
|
||||
@@ -1920,6 +1920,7 @@ export namespace main {
|
||||
from_radio: string;
|
||||
to_radio: string;
|
||||
recording_device: string;
|
||||
digi_input: string;
|
||||
listening_device: string;
|
||||
qso_record: boolean;
|
||||
qso_dir: string;
|
||||
@@ -1942,6 +1943,7 @@ export namespace main {
|
||||
this.from_radio = source["from_radio"];
|
||||
this.to_radio = source["to_radio"];
|
||||
this.recording_device = source["recording_device"];
|
||||
this.digi_input = source["digi_input"];
|
||||
this.listening_device = source["listening_device"];
|
||||
this.qso_record = source["qso_record"];
|
||||
this.qso_dir = source["qso_dir"];
|
||||
|
||||
@@ -79,6 +79,10 @@ func acquireInstance(wait bool) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// postExitSettle is the pause between the previous instance ending and this
|
||||
// one claiming its place. 400 ms, the figure the PowerShell helper used.
|
||||
const postExitSettle = 400 * time.Millisecond
|
||||
|
||||
// waitPidArg reads "--wait-pid N": the process this one must outlive.
|
||||
func waitPidArg(args []string) int {
|
||||
for i, a := range args {
|
||||
@@ -131,6 +135,17 @@ func main() {
|
||||
} else {
|
||||
bootLog("the previous instance (pid %d) is STILL running after 60s — trying anyway", pid)
|
||||
}
|
||||
// A breath after it is gone, which the PowerShell helper took as
|
||||
// "Start-Sleep -Milliseconds 400" and the direct launch dropped.
|
||||
//
|
||||
// A process's handles are released by the kernel as it dies, so the
|
||||
// mutex is free the instant the wait returns — but the things AROUND
|
||||
// it are not on that clock: the WebView2 user-data lock, the log file,
|
||||
// an antivirus that woke up when the exe was replaced. The old code
|
||||
// waited here and worked; this is not a theory about which of those it
|
||||
// was, it is the pause being put back.
|
||||
time.Sleep(postExitSettle)
|
||||
bootLog("settled %v after the previous instance — taking the lock", postExitSettle)
|
||||
}
|
||||
// A self-relaunch (database switch) races its own parent: the new process
|
||||
// regularly wins the start against the old one's teardown, and the operator
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// detachProcess is a no-op off Windows: the relaunch there is an ordinary fork
|
||||
// and nothing is inherited that needs breaking.
|
||||
func detachProcess(cmd *exec.Cmd) {}
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// detachProcess starts the child on its own, the way PowerShell's Start-Process
|
||||
// left the relaunched OpsLog.
|
||||
//
|
||||
// DETACHED_PROCESS gives it no console of ours, CREATE_NEW_PROCESS_GROUP takes
|
||||
// it out of our group so a Ctrl-Break or a job-object cleanup aimed at the old
|
||||
// instance cannot reach the new one. Deliberately NOT CREATE_NO_WINDOW and NOT
|
||||
// HideWindow: SW_HIDE in the STARTUPINFO is what made the updated OpsLog start
|
||||
// invisibly, and there is no console to suppress — OpsLog is linked for the
|
||||
// Windows GUI subsystem.
|
||||
func detachProcess(cmd *exec.Cmd) {
|
||||
const (
|
||||
detachedProcess = 0x00000008
|
||||
createNewProcessGroup = 0x00000200
|
||||
)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
CreationFlags: detachedProcess | createNewProcessGroup,
|
||||
}
|
||||
}
|
||||
+15
-3
@@ -27,11 +27,23 @@ import (
|
||||
// other caller still belongs. Two self-relaunches then differed by that one
|
||||
// line, and only the hidden one was ever reported broken.
|
||||
//
|
||||
// So: no SysProcAttr at all, which is what RestartApp already did and why the
|
||||
// database-switch relaunch never showed the fault. There is no console to
|
||||
// suppress either way — OpsLog is linked for the Windows GUI subsystem.
|
||||
// So: nothing that touches the WINDOW. RestartApp never called hideConsole and
|
||||
// the database-switch relaunch never showed the fault, which is what proved it.
|
||||
// There is no console to suppress either way — OpsLog is linked for the Windows
|
||||
// GUI subsystem. What SysProcAttr does carry now is detachment, below, which is
|
||||
// about process parentage and not about the window.
|
||||
// Two things the PowerShell helper did that the direct launch did not, both
|
||||
// restored here after operators kept reporting the relaunch failing:
|
||||
//
|
||||
// 1. It launched through Start-Process, so the new OpsLog was a child of
|
||||
// PowerShell — which then exited. The direct launch makes it a child of
|
||||
// the OpsLog that is dying, inside the same process group and console.
|
||||
// detachProcess puts it back on its own.
|
||||
// 2. It slept 400 ms after the old process was gone, before starting
|
||||
// anything. See postExitSettle in main.go.
|
||||
func relaunchCmd(exe string, args ...string) *exec.Cmd {
|
||||
cmd := exec.Command(exe, args...)
|
||||
cmd.Dir = filepath.Dir(exe)
|
||||
detachProcess(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
+20
-15
@@ -17,13 +17,10 @@ import (
|
||||
// load": a process in the task manager, no window, and killing it then starting
|
||||
// it by hand working every time.
|
||||
//
|
||||
// Nil, not "some specific value": there is nothing a self-relaunch needs from
|
||||
// STARTUPINFO, and anything set there is a window flag waiting to be wrong.
|
||||
func TestRelaunchCmdDoesNotTouchTheWindow(t *testing.T) {
|
||||
// The window flags themselves are asserted in relaunch_windows_test.go, where
|
||||
// SysProcAttr has the fields to look at.
|
||||
func TestRelaunchCmdPassesItsArgumentsAndFolder(t *testing.T) {
|
||||
cmd := relaunchCmd(filepath.Join("C:", "OpsLog", "OpsLog.exe"), "--post-update", "--wait-pid", "1234")
|
||||
if cmd.SysProcAttr != nil {
|
||||
t.Errorf("relaunchCmd set SysProcAttr = %+v; a self-relaunch must leave the window alone", cmd.SysProcAttr)
|
||||
}
|
||||
if len(cmd.Args) != 4 || cmd.Args[1] != "--post-update" || cmd.Args[3] != "1234" {
|
||||
t.Errorf("args = %v, want the exe plus the three passed through", cmd.Args)
|
||||
}
|
||||
@@ -35,19 +32,27 @@ func TestRelaunchCmdDoesNotTouchTheWindow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// hideConsole is right for the console tools and wrong for OpsLog, and the two
|
||||
// live a few lines apart. This is the guard that stops the update path
|
||||
// borrowing it again — which is how it broke the first time, when the
|
||||
// PowerShell helper was replaced by a direct exec.Command beside them.
|
||||
func TestUpdateRelaunchDoesNotHideTheWindow(t *testing.T) {
|
||||
// The update relaunch goes through a helper that OUTLIVES this process.
|
||||
//
|
||||
// Two rewrites started the new exe from here instead, and both left operators
|
||||
// with no window after an update: the launch then happens while this process is
|
||||
// still alive and still holds the mutex. The helper waits for our pid first.
|
||||
// Restored from before 0430aab and pinned here so a third rewrite has to argue
|
||||
// with the two reports rather than rediscover them.
|
||||
func TestUpdateRelaunchWaitsForUsFromOutside(t *testing.T) {
|
||||
src, err := os.ReadFile("update.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read update.go: %v", err)
|
||||
}
|
||||
if strings.Contains(string(src), "hideConsole(") {
|
||||
t.Error("update.go calls hideConsole — a relaunch of OpsLog must not hide its window (see relaunch.go)")
|
||||
got := string(src)
|
||||
for _, want := range []string{"Wait-Process -Id", "Start-Process -FilePath"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("update.go no longer contains %q — the relaunch must wait for this process from outside it", want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(src), "relaunchCmd(exe,") {
|
||||
t.Error("update.go no longer relaunches through relaunchCmd, where that rule is written down")
|
||||
// Start-Process shows the new window normally. A direct exec.Command(exe…)
|
||||
// here is the shape that broke it, twice.
|
||||
if strings.Contains(got, "exec.Command(exe") {
|
||||
t.Error("update.go starts the new exe directly again")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A relaunch of OpsLog itself must not suppress the new process's window.
|
||||
//
|
||||
// HideWindow becomes SW_HIDE in the STARTUPINFO, and Windows applies it to the
|
||||
// first top-level window the new process shows. That is how the updated OpsLog
|
||||
// came to start perfectly — mutex taken, rig connected — and stay invisible:
|
||||
// two operators on 0.27.23 reported a process in the task manager, no window,
|
||||
// and killing it then starting OpsLog by hand working every time.
|
||||
//
|
||||
// CREATE_NO_WINDOW is refused for the same reason it is pointless: there is no
|
||||
// console to suppress on a GUI-subsystem binary, and it is one flag away from
|
||||
// the one that broke this.
|
||||
func TestRelaunchNeverHidesTheWindow(t *testing.T) {
|
||||
cmd := relaunchCmd(filepath.Join("C:", "OpsLog", "OpsLog.exe"), "--post-update")
|
||||
if cmd.SysProcAttr == nil {
|
||||
t.Fatal("no SysProcAttr — the relaunch should be detached (see detachProcess)")
|
||||
}
|
||||
if cmd.SysProcAttr.HideWindow {
|
||||
t.Error("HideWindow is set: the relaunched OpsLog would start with no window")
|
||||
}
|
||||
const createNoWindow = 0x08000000
|
||||
if cmd.SysProcAttr.CreationFlags&createNoWindow != 0 {
|
||||
t.Error("CREATE_NO_WINDOW is set on a GUI-subsystem relaunch")
|
||||
}
|
||||
}
|
||||
|
||||
// Detached and in its own process group, which is what Start-Process gave the
|
||||
// relaunch before the PowerShell helper was removed. Being a child of the
|
||||
// instance that is dying is the one difference from the code that worked.
|
||||
func TestRelaunchIsDetached(t *testing.T) {
|
||||
cmd := relaunchCmd(filepath.Join("C:", "OpsLog", "OpsLog.exe"), "--post-update")
|
||||
const (
|
||||
detachedProcess = 0x00000008
|
||||
createNewProcessGroup = 0x00000200
|
||||
)
|
||||
if cmd.SysProcAttr.CreationFlags&detachedProcess == 0 {
|
||||
t.Error("DETACHED_PROCESS is missing: the new instance keeps the old one's console")
|
||||
}
|
||||
if cmd.SysProcAttr.CreationFlags&createNewProcessGroup == 0 {
|
||||
t.Error("CREATE_NEW_PROCESS_GROUP is missing: a cleanup aimed at the old instance can reach the new one")
|
||||
}
|
||||
}
|
||||
+12
-7
@@ -25,15 +25,20 @@ func TestWaitPidArg(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Every relaunch has to tell the new process which one to wait for.
|
||||
// A relaunch started from INSIDE this process has to tell the new one which
|
||||
// process to wait for.
|
||||
//
|
||||
// The auto-update relaunch lost that when its PowerShell helper was removed —
|
||||
// the helper had waited for the pid, and nothing took over the job — and an
|
||||
// operator was left with no window after an update and the previous OpsLog
|
||||
// still running. This keeps the two spawn sites honest: if a relaunch is added
|
||||
// without --wait-pid, it is the same bug again.
|
||||
// The update path no longer does that from inside: its PowerShell helper waits
|
||||
// for the pid and starts the exe once we are gone, which is what two rewrites
|
||||
// failed to reproduce. RestartApp still spawns directly, and if a relaunch is
|
||||
// ever added the same way without --wait-pid, it is the old bug again.
|
||||
func TestEveryRelaunchPassesItsPid(t *testing.T) {
|
||||
spawn := regexp.MustCompile(`exec\.Command\(exe, "--(post-update|relaunch)"[^)]*\)`)
|
||||
// Both spellings: a relaunch built inline with exec.Command, and one built
|
||||
// through relaunchCmd. The pattern used to name only the first, and when the
|
||||
// update path moved to a PowerShell helper and RestartApp to relaunchCmd it
|
||||
// quietly matched nothing at all — a guard that passes because it looks
|
||||
// nowhere.
|
||||
spawn := regexp.MustCompile(`(exec\.Command|relaunchCmd)\(exe, "--(post-update|relaunch)"[^)]*\)`)
|
||||
for _, file := range []string{"update.go", "app.go"} {
|
||||
src, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.27.24"
|
||||
appVersion = "0.27.25"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -213,38 +214,48 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
|
||||
}
|
||||
applog.Printf("update: installed new build, scheduling relaunch")
|
||||
|
||||
// THE NEW EXE STARTS ITSELF. No helper, no script.
|
||||
// A DETACHED, HIDDEN POWERSHELL waits for this process to exit and THEN
|
||||
// starts the new exe. Restored, verbatim, from before 0430aab.
|
||||
//
|
||||
// This used to go through a hidden PowerShell that waited for our process to
|
||||
// die and then launched the new image — which is, byte for byte, the shape of
|
||||
// a dropper: an unsigned binary replaces itself on disk, clears the
|
||||
// mark-of-the-web, and spawns a windowless PowerShell that starts another
|
||||
// executable. Windows Defender's machine-learning model reads that shape and
|
||||
// not our intentions, and an operator updating to 0.27.14 had OpsLog removed
|
||||
// under Trojan:Script/Wacatac.H!ml — the "Script/" being the PowerShell.
|
||||
// Two rewrites tried to do without it and both failed on real stations.
|
||||
// Starting the new exe from here means launching it while this process is
|
||||
// still alive — and the comment on the original said exactly what that
|
||||
// costs: "Launching the new exe directly while we're still alive raced the
|
||||
// mutex and often left nothing running". Telling the new instance our pid so
|
||||
// it could wait on the other side looked equivalent and was not; operators
|
||||
// kept reporting no window after an update, on 0.27.23 and again after.
|
||||
// What the helper has that neither rewrite did is that it OUTLIVES us: the
|
||||
// launch happens after this process is completely gone, from a process that
|
||||
// was never our child.
|
||||
//
|
||||
// The wait it existed for still has to happen — it just happens on the other
|
||||
// side now. The new instance is told OUR pid and waits for this process to
|
||||
// end before taking the single-instance mutex.
|
||||
// The cost is known and accepted. Windows Defender removed 0.27.14 from a
|
||||
// station as Trojan:Script/Wacatac.H!ml: an unsigned binary that replaces
|
||||
// itself, clears the mark-of-the-web and spawns a windowless script to start
|
||||
// another executable has the shape of a dropper, and the model reads shapes,
|
||||
// not intentions. The operator's answer is to allow OpsLog in Defender. An
|
||||
// updater that works and occasionally needs whitelisting beats one that
|
||||
// leaves people with no running program.
|
||||
//
|
||||
// Waiting on the mutex alone was not enough: shutting down is allowed thirty
|
||||
// seconds here (armExitWatchdog), because it closes a remote logbook, a CAT
|
||||
// session and sometimes a backup, while the new instance was only patient
|
||||
// for twenty. On a station where that ran long, the new process gave up and
|
||||
// exited — leaving the old one still running and no new window, which is
|
||||
// precisely what the PowerShell helper never did: it waited for the pid,
|
||||
// however long it took.
|
||||
//
|
||||
// And relaunchCmd rather than a command built here, because the OTHER half
|
||||
// of the same report was this line calling hideConsole: SW_HIDE in the
|
||||
// STARTUPINFO, which Windows applies to the new process's first window. The
|
||||
// updated OpsLog started, took the mutex, and stayed invisible. See
|
||||
// relaunch.go — the fact belongs in one place, since two self-relaunches
|
||||
// differing by one line is how only one of them was broken.
|
||||
cmd := relaunchCmd(exe, "--post-update", "--wait-pid", strconv.Itoa(os.Getpid()))
|
||||
// HideWindow here is right and is NOT the bug that made the updated OpsLog
|
||||
// invisible: it hides POWERSHELL's console, which is the whole point. The
|
||||
// new OpsLog is started by Start-Process, with a normal show.
|
||||
quoted := strings.ReplaceAll(exe, "'", "''")
|
||||
ps := fmt.Sprintf(
|
||||
"Wait-Process -Id %d -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 400; Start-Process -FilePath '%s' -ArgumentList '--post-update'",
|
||||
os.Getpid(), quoted)
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
|
||||
cmd.Dir = dir
|
||||
hideConsole(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
applog.Printf("update: the relaunch could not be started: %v", err)
|
||||
return fmt.Errorf("schedule relaunch: %w", err)
|
||||
}
|
||||
// The HELPER's pid, so the log says the launcher was started and not just
|
||||
// that we meant to. The new OpsLog logs its own arrival in startup.log; the
|
||||
// two together tell "the helper never ran" from "it ran and the exe did not
|
||||
// start", which have different causes.
|
||||
applog.Printf("update: relaunch helper started as pid %d — it waits for this process (pid %d) to exit, then starts %s",
|
||||
cmd.Process.Pid, os.Getpid(), filepath.Base(exe))
|
||||
// Released rather than waited on: this process is about to exit, and a child
|
||||
// that outlives its parent must not be left as a zombie handle.
|
||||
_ = cmd.Process.Release()
|
||||
|
||||
Reference in New Issue
Block a user