Модуль, снятый с лицензии, отключается один раз: служба остановлена (bj-keymedia unit-disable), одно оповещение, в списках не показывается; вернули в лицензию — служба включается
Также: версия файла, совпадающего с выпуском, записывается (нет ложного «новее»); признак неудачной проверки отдельно от результатов установки; окно обновления само перезагружает страницу. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
@@ -25,6 +25,15 @@ process_one(){
|
|||||||
fi ;;
|
fi ;;
|
||||||
*) out="{\"ok\":false,\"error\":\"недопустимая служба\"}" ;;
|
*) out="{\"ok\":false,\"error\":\"недопустимая служба\"}" ;;
|
||||||
esac ;;
|
esac ;;
|
||||||
|
unit-disable|unit-enable)
|
||||||
|
# службы модулей по лицензии: модуль сняли с лицензии — остановить и отключить, вернули — включить
|
||||||
|
case "$extra" in
|
||||||
|
bj-npr|bj-quikexport|bj-lk)
|
||||||
|
if [ "$action" = unit-disable ]; then err=$(systemctl disable --now "$extra" 2>&1); else systemctl reset-failed "$extra" >/dev/null 2>&1; err=$(systemctl enable --now "$extra" 2>&1); fi
|
||||||
|
if [ $? -eq 0 ]; then out="{\"ok\":true,\"output\":\"$action: $extra\"}"
|
||||||
|
else err=$(printf '%s' "$err" | tr -d '"\\' | tr '\n' ' '); out="{\"ok\":false,\"error\":\"$extra: $err\"}"; fi ;;
|
||||||
|
*) out="{\"ok\":false,\"error\":\"недопустимая служба\"}" ;;
|
||||||
|
esac ;;
|
||||||
*) out=$(BJ_TARGET_DEV="$tdev" BJ_TARGET_MNT="$tmnt" BJ_EXTRA="$extra" /usr/local/sbin/bj-keymedia-install "$action" "$staging" "$profile" 2>&1) ;;
|
*) out=$(BJ_TARGET_DEV="$tdev" BJ_TARGET_MNT="$tmnt" BJ_EXTRA="$extra" /usr/local/sbin/bj-keymedia-install "$action" "$staging" "$profile" 2>&1) ;;
|
||||||
esac
|
esac
|
||||||
printf '%s' "$out" > "$res.tmp"; chown bj:bj "$res.tmp" 2>/dev/null
|
printf '%s' "$out" > "$res.tmp"; chown bj:bj "$res.tmp" 2>/dev/null
|
||||||
|
|||||||
@@ -168,7 +168,11 @@ func LicenseRenew(ctx context.Context, rc *RuntimeConfig) error {
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return licenseAccept(rc, r.Token, "продлена")
|
if err := licenseAccept(rc, r.Token, "продлена"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
enforceModuleLicenseAsync(rc) // состав лицензии мог измениться — службы модулей в соответствие
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// licenseNeedsRenew — онлайн-ключ и 15 минут без продления: так режим разработки, включённый или
|
// licenseNeedsRenew — онлайн-ключ и 15 минут без продления: так режим разработки, включённый или
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package lkgateway
|
||||||
|
|
||||||
|
// Модули, снятые с лицензии: служба модуля останавливается и отключается один раз, с одним
|
||||||
|
// оповещением; дальше модуль не показывается и не предлагается к обновлению. Вернули в лицензию —
|
||||||
|
// служба включается обратно. Работает только при действующей лицензии: истёкший или непроверенный
|
||||||
|
// ключ модули не трогает (временная ошибка не должна гасить работающие службы).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
|
||||||
|
)
|
||||||
|
|
||||||
|
var moduleEnforceMu sync.Mutex
|
||||||
|
|
||||||
|
// moduleOff — модуль отключён как снятый с лицензии (по записи в настройках).
|
||||||
|
func moduleOff(rc *RuntimeConfig, name string) bool {
|
||||||
|
_, ok := rc.Snapshot().Update.DisabledModules[name]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func enforceModuleLicense(ctx context.Context, rc *RuntimeConfig) {
|
||||||
|
moduleEnforceMu.Lock()
|
||||||
|
defer moduleEnforceMu.Unlock()
|
||||||
|
if _, err := os.Stat(keymediaReqDir); err != nil {
|
||||||
|
return // нет root-службы (разработка, тесты)
|
||||||
|
}
|
||||||
|
st := licenseStatus(rc)
|
||||||
|
if !st.Valid {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := rc.Snapshot().Update
|
||||||
|
changed := false
|
||||||
|
for _, c := range release.Components {
|
||||||
|
if c.Core || c.Feature == "" || c.Feature == "updates" || c.Unit == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(c.Path); err != nil {
|
||||||
|
continue // модуль не установлен
|
||||||
|
}
|
||||||
|
allowed := moduleAllowed(rc, c.Feature)
|
||||||
|
_, off := cfg.DisabledModules[c.Name]
|
||||||
|
switch {
|
||||||
|
case !allowed && !off:
|
||||||
|
if _, err := runKeymediaOp(ctx, "unit-disable", map[string]string{"extra": c.Unit}); err != nil {
|
||||||
|
log.Printf("modules: отключение %s: %v", c.Unit, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cfg.DisabledModules == nil {
|
||||||
|
cfg.DisabledModules = map[string]string{}
|
||||||
|
}
|
||||||
|
cfg.DisabledModules[c.Name] = time.Now().Format("02.01.2006 15:04")
|
||||||
|
changed = true
|
||||||
|
log.Printf("modules: %s снят с лицензии — служба %s остановлена и отключена", c.Name, c.Unit)
|
||||||
|
raiseSystemAlert(rc, "Модуль «"+c.Title+"» отключён: не входит в лицензию",
|
||||||
|
"Служба "+c.Unit+" остановлена, раздел скрыт, обновления для модуля не выдаются. Чтобы подключить модуль, свяжитесь с ZETIT — после добавления в лицензию он включится сам.")
|
||||||
|
case allowed && off:
|
||||||
|
if _, err := runKeymediaOp(ctx, "unit-enable", map[string]string{"extra": c.Unit}); err != nil {
|
||||||
|
log.Printf("modules: включение %s: %v", c.Unit, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(cfg.DisabledModules, c.Name)
|
||||||
|
changed = true
|
||||||
|
log.Printf("modules: %s снова в лицензии — служба %s включена", c.Name, c.Unit)
|
||||||
|
raiseSystemAlert(rc, "Модуль «"+c.Title+"» снова включён", "Модуль добавлен в лицензию, служба "+c.Unit+" запущена.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
cur := rc.Snapshot().Update
|
||||||
|
cur.DisabledModules = cfg.DisabledModules
|
||||||
|
if err := rc.SaveUpdateSettings(cur); err != nil {
|
||||||
|
log.Printf("modules: сохранение: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enforceModuleLicenseAsync(rc *RuntimeConfig) {
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
enforceModuleLicense(ctx, rc)
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -92,6 +92,12 @@ type UpdateSettings struct {
|
|||||||
AutoCheck bool `json:"auto_check"` // проверять автоматически
|
AutoCheck bool `json:"auto_check"` // проверять автоматически
|
||||||
LastCheck time.Time `json:"last_check"` // когда последний раз проверяли
|
LastCheck time.Time `json:"last_check"` // когда последний раз проверяли
|
||||||
LastResult string `json:"last_result"` // текст результата проверки
|
LastResult string `json:"last_result"` // текст результата проверки
|
||||||
|
// CheckFailed — последняя ПРОВЕРКА обновлений не удалась (сервер недоступен, нет лицензии…):
|
||||||
|
// тогда версии из прошлой проверки не предлагаются. Неудачная установка сюда не относится.
|
||||||
|
CheckFailed bool `json:"check_failed,omitempty"`
|
||||||
|
// DisabledModules — модули, отключённые как снятые с лицензии (имя → когда): служба остановлена,
|
||||||
|
// в списке компонентов не показываются; при возврате в лицензию включаются обратно.
|
||||||
|
DisabledModules map[string]string `json:"disabled_modules,omitempty"`
|
||||||
Available string `json:"available_version"` // доступная версия (если новее)
|
Available string `json:"available_version"` // доступная версия (если новее)
|
||||||
Notes string `json:"notes,omitempty"` // заметки доступного релиза
|
Notes string `json:"notes,omitempty"` // заметки доступного релиза
|
||||||
// Components — версии компонентов в последнем проверенном релизе (имя → версия).
|
// Components — версии компонентов в последнем проверенном релизе (имя → версия).
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ func unitViews(rc *RuntimeConfig) []UnitView {
|
|||||||
if !present && !c.Core {
|
if !present && !c.Core {
|
||||||
continue // модуля на сервере нет — и службы быть не должно
|
continue // модуля на сервере нет — и службы быть не должно
|
||||||
}
|
}
|
||||||
|
if moduleOff(rc, c.Name) {
|
||||||
|
continue // снят с лицензии: служба отключена намеренно
|
||||||
|
}
|
||||||
v := unitState(c.Unit)
|
v := unitState(c.Unit)
|
||||||
v.Title, v.Core, v.Present = c.Title, c.Core, true
|
v.Title, v.Core, v.Present = c.Title, c.Core, true
|
||||||
out = append(out, v)
|
out = append(out, v)
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ package lkgateway
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -95,6 +98,7 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) {
|
|||||||
return st, err
|
return st, err
|
||||||
}
|
}
|
||||||
st.Available, st.Notes = m.Version, m.Notes
|
st.Available, st.Notes = m.Version, m.Notes
|
||||||
|
syncSameFileVersions(m)
|
||||||
st.Components = release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion)
|
st.Components = release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion)
|
||||||
release.MarkSplitModules(st.Components, u.allowed)
|
release.MarkSplitModules(st.Components, u.allowed)
|
||||||
off := markUnlicensed(st.Components, u.allowed)
|
off := markUnlicensed(st.Components, u.allowed)
|
||||||
@@ -121,9 +125,59 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) {
|
|||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncSameFileVersions — если файл компонента на сервере побайтно совпадает с файлом выпуска, а
|
||||||
|
// записана другая версия (компонент не менялся, а номер выпуска вырос, или файл выложен вручную),
|
||||||
|
// записываем версию выпуска: иначе список предлагает «обновить» то, что установщик отклонит как
|
||||||
|
// «обновление не требуется».
|
||||||
|
func syncSameFileVersions(m *release.Manifest) {
|
||||||
|
installed := release.LoadInstalled(release.InstalledFile)
|
||||||
|
changed := false
|
||||||
|
for _, a := range m.Artifacts {
|
||||||
|
c, ok := release.ComponentByName(a.Name)
|
||||||
|
if !ok || a.SHA256 == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := a.Version
|
||||||
|
if v == "" {
|
||||||
|
v = m.Version
|
||||||
|
}
|
||||||
|
if cur, ok := installed[a.Name]; ok && cur.Version == v {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sum, err := fileSHA256(c.Path)
|
||||||
|
if err != nil || !strings.EqualFold(sum, a.SHA256) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
installed[a.Name] = release.Installed{Version: v, SHA256: sum, InstalledAt: time.Now().UTC()}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
if err := release.SaveInstalled(release.InstalledFile, installed); err != nil {
|
||||||
|
log.Printf("lk-gateway: запись версий совпадающих компонентов: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileSHA256(path string) (string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
// markUnlicensed — модули, установленные на сервере, но снятые с лицензии: не обновляются (сервер
|
// markUnlicensed — модули, установленные на сервере, но снятые с лицензии: не обновляются (сервер
|
||||||
// обновлений их файлы не отдаёт), в списке — с пояснением. Возвращает названия таких модулей.
|
// обновлений их файлы не отдаёт), в списке — с пояснением. Возвращает названия таких модулей.
|
||||||
func markUnlicensed(states []release.ComponentState, allowed func(string) bool) []string {
|
func markUnlicensed(states []release.ComponentState, allowed func(string) bool) []string {
|
||||||
|
var disabled map[string]string
|
||||||
|
if globalRC != nil {
|
||||||
|
disabled = globalRC.Snapshot().Update.DisabledModules
|
||||||
|
}
|
||||||
var off []string
|
var off []string
|
||||||
for i := range states {
|
for i := range states {
|
||||||
c := &states[i]
|
c := &states[i]
|
||||||
@@ -131,7 +185,7 @@ func markUnlicensed(states []release.ComponentState, allowed func(string) bool)
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
c.Update, c.New = false, false
|
c.Update, c.New = false, false
|
||||||
if c.Present {
|
if _, already := disabled[c.Name]; c.Present && !already { // об отключённом уже сказано один раз
|
||||||
c.Blocked = "не входит в лицензию — модуль отключён и не обновляется; чтобы подключить, свяжитесь с ZETIT"
|
c.Blocked = "не входит в лицензию — модуль отключён и не обновляется; чтобы подключить, свяжитесь с ZETIT"
|
||||||
off = append(off, "«"+c.Title+"»")
|
off = append(off, "«"+c.Title+"»")
|
||||||
}
|
}
|
||||||
@@ -163,6 +217,7 @@ func (u *Updater) saveCheckResult(st UpdateStatus, avail map[string]string) {
|
|||||||
cfg := u.rc.Snapshot().Update
|
cfg := u.rc.Snapshot().Update
|
||||||
cfg.LastCheck = time.Now().UTC()
|
cfg.LastCheck = time.Now().UTC()
|
||||||
cfg.LastResult = st.Message
|
cfg.LastResult = st.Message
|
||||||
|
cfg.CheckFailed = avail == nil // nil — манифест получить не удалось
|
||||||
cfg.Available = st.Available
|
cfg.Available = st.Available
|
||||||
cfg.Notes = st.Notes
|
cfg.Notes = st.Notes
|
||||||
if avail != nil {
|
if avail != nil {
|
||||||
@@ -202,6 +257,7 @@ func (u *Updater) applyUpdate(ctx context.Context, names []string) (string, erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("манифест: %w", err)
|
return "", fmt.Errorf("манифест: %w", err)
|
||||||
}
|
}
|
||||||
|
syncSameFileVersions(m)
|
||||||
plan := release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion)
|
plan := release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion)
|
||||||
release.MarkSplitModules(plan, u.allowed)
|
release.MarkSplitModules(plan, u.allowed)
|
||||||
off := markUnlicensed(plan, u.allowed)
|
off := markUnlicensed(plan, u.allowed)
|
||||||
@@ -414,7 +470,10 @@ func updateComponentRows(rc *RuntimeConfig) ([]release.ComponentState, bool) {
|
|||||||
r.Update = r.Available != "" && (r.Present || r.Core) && (r.Installed == "" || release.IsNewer(r.Available, r.Installed))
|
r.Update = r.Available != "" && (r.Present || r.Core) && (r.Installed == "" || release.IsNewer(r.Available, r.Installed))
|
||||||
// Модуль, которого нет на сервере и нет в лицензии, не показываем: поставить его нельзя,
|
// Модуль, которого нет на сервере и нет в лицензии, не показываем: поставить его нельзя,
|
||||||
// а в списке он выглядит так, будто входит в поставку.
|
// а в списке он выглядит так, будто входит в поставку.
|
||||||
if r.Present || r.Core || (r.Available != "" && moduleAllowed(rc, r.Feature)) {
|
// Модуль вне лицензии не показываем вовсе (и установленный: его служба отключена), пока
|
||||||
|
// лицензия действует; без действующей лицензии список как есть.
|
||||||
|
unlicensed := !r.Core && r.Feature != "" && r.Feature != "updates" && licenseStatus(rc).Valid && !moduleAllowed(rc, r.Feature)
|
||||||
|
if !unlicensed && (r.Present || r.Core || (r.Available != "" && moduleAllowed(rc, r.Feature))) {
|
||||||
out = append(out, r)
|
out = append(out, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -422,7 +481,7 @@ func updateComponentRows(rc *RuntimeConfig) ([]release.ComponentState, bool) {
|
|||||||
markUnlicensed(out, func(f string) bool { return moduleAllowed(rc, f) })
|
markUnlicensed(out, func(f string) bool { return moduleAllowed(rc, f) })
|
||||||
// Последняя проверка не удалась — предлагать установку по старым данным нельзя:
|
// Последняя проверка не удалась — предлагать установку по старым данным нельзя:
|
||||||
// кнопка ушла бы в тот же отказ. Показываем причину и просим проверить заново.
|
// кнопка ушла бы в тот же отказ. Показываем причину и просим проверить заново.
|
||||||
if updateResultFailed(set.LastResult) {
|
if set.CheckFailed {
|
||||||
for i := range out {
|
for i := range out {
|
||||||
if out[i].Update && out[i].Available != "" {
|
if out[i].Update && out[i].Available != "" {
|
||||||
out[i].Blocked = "проверка обновлений не удалась — нажмите «Проверить обновления»"
|
out[i].Blocked = "проверка обновлений не удалась — нажмите «Проверить обновления»"
|
||||||
@@ -476,6 +535,7 @@ func (u *Updater) updateLoop(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
case <-time.After(time.Minute):
|
case <-time.After(time.Minute):
|
||||||
cleanupStaging(time.Hour)
|
cleanupStaging(time.Hour)
|
||||||
|
enforceModuleLicense(ctx, u.rc)
|
||||||
u.ensureSplitModules(ctx)
|
u.ensureSplitModules(ctx)
|
||||||
check()
|
check()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -593,12 +593,22 @@ label { font-size:13px; font-weight:500; }
|
|||||||
detail=document.getElementById('upd-detail'), doneEl=document.getElementById('upd-done');
|
detail=document.getElementById('upd-detail'), doneEl=document.getElementById('upd-done');
|
||||||
function mb(n){ return (n/1048576).toFixed(n<10485760?1:0)+' МБ'; }
|
function mb(n){ return (n/1048576).toFixed(n<10485760?1:0)+' МБ'; }
|
||||||
function show(){ modal.style.display='flex'; }
|
function show(){ modal.style.display='flex'; }
|
||||||
|
var finished=false;
|
||||||
function finish(text, ok){
|
function finish(text, ok){
|
||||||
|
finished=true;
|
||||||
stageEl.textContent=text; bar.style.width='100%';
|
stageEl.textContent=text; bar.style.width='100%';
|
||||||
bar.style.background= ok ? 'var(--ok,#2e8b57)' : 'var(--err,#c0392b)';
|
bar.style.background= ok ? 'var(--ok,#2e8b57)' : 'var(--err,#c0392b)';
|
||||||
doneEl.style.display='block';
|
doneEl.style.display='block';
|
||||||
|
if(ok){ // после успешного обновления страница перезагружается сама — с новыми версиями
|
||||||
|
var left=5, tick=function(){ if(left<=0){ location.reload(); return; }
|
||||||
|
doneEl.querySelector('button').textContent='Готово (страница обновится через '+left+' с)'; left--; setTimeout(tick,1000); };
|
||||||
|
tick();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function run(form){
|
function run(form){
|
||||||
|
var lastChange=Date.now(), lastKey='';
|
||||||
|
// сторож: если отчёт не меняется 2 минуты, перезагружаем страницу — не оставляем «зависшее» окно
|
||||||
|
(function watch(){ if(finished) return; if(Date.now()-lastChange>120000){ location.reload(); return; } setTimeout(watch,5000); })();
|
||||||
var note='', startVer=null, target='', seenActive=false, postDone=false, lost=false, t0=Date.now(), seq0=null;
|
var note='', startVer=null, target='', seenActive=false, postDone=false, lost=false, t0=Date.now(), seq0=null;
|
||||||
show(); stageEl.textContent='Отправка запроса…'; bar.style.width='3%'; detail.textContent='';
|
show(); stageEl.textContent='Отправка запроса…'; bar.style.width='3%'; detail.textContent='';
|
||||||
// номер последнего отчёта до отправки: отчёт прошлой попытки (например, с ошибкой) не показываем
|
// номер последнего отчёта до отправки: отчёт прошлой попытки (например, с ошибкой) не показываем
|
||||||
@@ -620,6 +630,7 @@ label { font-size:13px; font-weight:500; }
|
|||||||
function poll(){
|
function poll(){
|
||||||
fetch('/admin/setup/update/progress',{cache:'no-store',credentials:'same-origin'}).then(function(r){ if(!r.ok) throw 0; return r.json(); }).then(function(p){
|
fetch('/admin/setup/update/progress',{cache:'no-store',credentials:'same-origin'}).then(function(r){ if(!r.ok) throw 0; return r.json(); }).then(function(p){
|
||||||
if(startVer===null){ startVer=p.version; }
|
if(startVer===null){ startVer=p.version; }
|
||||||
|
var key=[p.seq,p.step,p.done,p.finished,p.version].join('/'); if(key!==lastKey){ lastKey=key; lastChange=Date.now(); }
|
||||||
if(p.target) target=p.target;
|
if(p.target) target=p.target;
|
||||||
if(p.note) note=p.note;
|
if(p.note) note=p.note;
|
||||||
if(lost){ // bj вернулся после перезапуска
|
if(lost){ // bj вернулся после перезапуска
|
||||||
|
|||||||
Reference in New Issue
Block a user