prim-ctrl 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: prim-ctrl
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Primitive Ctrl - Remote control of your phone's Primitive FTPd Android SFTP server and optionally Tailscale VPN.
5
5
  Home-page: https://github.com/lmagyar/prim-ctrl
6
6
  License: Apache-2.0
@@ -27,7 +27,7 @@ Remote control of your phone's [Primitive FTPd Android SFTP server](https://gith
27
27
 
28
28
  Though Primitive FTPd consumes minimal power when it is not used, remote start/stop can be usefull for zeroconf (DNS-SD). Android doesn't reply DSN-SD queries when the screen is off, but Android announces the new service when the Primitive FTPd server starts up. So SFTP clients can/should capture and cache the announcement at Primitive FTPd server startup to connect to a phone through zeroconf even when the screen is off.
29
29
 
30
- But in case of Tailscale, it is a real battery and mobile network data drain when not used, remote start/stop is de facto very useful.
30
+ But in case of Tailscale, it is a real battery and mobile network data drain when not used, local and remote start/stop is de facto very useful.
31
31
 
32
32
  With the help of this script you can sync your phone with eg. your home NAS server whereever your phone is on a WiFi network - or even on cellular. Your phone doesn't have to be on the same LAN to make zeroconf working when you have alternative access through VPN.
33
33
 
@@ -39,9 +39,9 @@ See my other project, https://github.com/lmagyar/prim-batch, for batch execution
39
39
 
40
40
  ## Features
41
41
 
42
- - Remote start/stop of Primitive FTPd Android SFTP server and optionally Tailscale VPN
42
+ - Remote start/stop of Primitive FTPd Android SFTP server and optionally local and remote start/stop of Tailscale VPN
43
43
  - Using VPN on cellular can be refused
44
- - Backup states before starting them, restore when stopping (ie. they won't be stopped if they were running before the script were asked to start them)
44
+ - Backup sftp server and VPN states before starting them, restore when stopping (ie. they won't be stopped if they were running before the script were asked to start them)
45
45
 
46
46
  ## Installation
47
47
 
@@ -163,7 +163,7 @@ usage: prim-ctrl Automate [-h] [-i {test,start,stop}] [-t] [-s] [--debug] [--tai
163
163
  Remote control of your phone's Primitive FTPd and optionally Tailscale app statuses via the Automate app, for more details see https://github.com/lmagyar/prim-ctrl
164
164
 
165
165
  Note: you must install Automate app on your phone, download prim-ctrl flow into it, and configure your Google account in the flow to receive messages (see the project's GitHub page for more details)
166
- Note: optionally if your phone is not accessible on local network but your laptop is part of the Tailscale VPN then Tailscale VPN can be started on the phone
166
+ Note: optionally if your phone is not accessible on local network but your laptop and phone is part of the Tailscale VPN then Tailscale VPN can be started on the phone
167
167
  Note: optionally if your laptop is accessible through Tailscale Funnel then VPN on cellular can be refused and app statuses on the phone can be backed up and restored
168
168
 
169
169
  Output: even when -b option is not used, the script will output 'connected=(local|remote)', what you can use to determine whether to use -a option for the prim-sync script
@@ -5,7 +5,7 @@ Remote control of your phone's [Primitive FTPd Android SFTP server](https://gith
5
5
 
6
6
  Though Primitive FTPd consumes minimal power when it is not used, remote start/stop can be usefull for zeroconf (DNS-SD). Android doesn't reply DSN-SD queries when the screen is off, but Android announces the new service when the Primitive FTPd server starts up. So SFTP clients can/should capture and cache the announcement at Primitive FTPd server startup to connect to a phone through zeroconf even when the screen is off.
7
7
 
8
- But in case of Tailscale, it is a real battery and mobile network data drain when not used, remote start/stop is de facto very useful.
8
+ But in case of Tailscale, it is a real battery and mobile network data drain when not used, local and remote start/stop is de facto very useful.
9
9
 
10
10
  With the help of this script you can sync your phone with eg. your home NAS server whereever your phone is on a WiFi network - or even on cellular. Your phone doesn't have to be on the same LAN to make zeroconf working when you have alternative access through VPN.
11
11
 
@@ -17,9 +17,9 @@ See my other project, https://github.com/lmagyar/prim-batch, for batch execution
17
17
 
18
18
  ## Features
19
19
 
20
- - Remote start/stop of Primitive FTPd Android SFTP server and optionally Tailscale VPN
20
+ - Remote start/stop of Primitive FTPd Android SFTP server and optionally local and remote start/stop of Tailscale VPN
21
21
  - Using VPN on cellular can be refused
22
- - Backup states before starting them, restore when stopping (ie. they won't be stopped if they were running before the script were asked to start them)
22
+ - Backup sftp server and VPN states before starting them, restore when stopping (ie. they won't be stopped if they were running before the script were asked to start them)
23
23
 
24
24
  ## Installation
25
25
 
@@ -141,7 +141,7 @@ usage: prim-ctrl Automate [-h] [-i {test,start,stop}] [-t] [-s] [--debug] [--tai
141
141
  Remote control of your phone's Primitive FTPd and optionally Tailscale app statuses via the Automate app, for more details see https://github.com/lmagyar/prim-ctrl
142
142
 
143
143
  Note: you must install Automate app on your phone, download prim-ctrl flow into it, and configure your Google account in the flow to receive messages (see the project's GitHub page for more details)
144
- Note: optionally if your phone is not accessible on local network but your laptop is part of the Tailscale VPN then Tailscale VPN can be started on the phone
144
+ Note: optionally if your phone is not accessible on local network but your laptop and phone is part of the Tailscale VPN then Tailscale VPN can be started on the phone
145
145
  Note: optionally if your laptop is accessible through Tailscale Funnel then VPN on cellular can be refused and app statuses on the phone can be backed up and restored
146
146
 
147
147
  Output: even when -b option is not used, the script will output 'connected=(local|remote)', what you can use to determine whether to use -a option for the prim-sync script
@@ -1,6 +1,7 @@
1
1
 
2
2
  import argparse
3
3
  import asyncio
4
+ import json
4
5
  import logging
5
6
  import os
6
7
  import platform
@@ -114,6 +115,26 @@ async def async_ping(host, packets: int = 1, timeout: float = 1):
114
115
  _stdout, _stderr = await proc.communicate()
115
116
  return proc.returncode == 0
116
117
 
118
+ async def async_tailscale(args: list[str]):
119
+ command = ['tailscale']
120
+ command.extend(args)
121
+ creationflags = subprocess.CREATE_NO_WINDOW if platform.system().lower() == 'windows' else 0
122
+ proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=creationflags)
123
+ stdout, _stderr = await proc.communicate()
124
+ return proc.returncode == 0, stdout
125
+
126
+ async def async_tailscale_up():
127
+ return (await async_tailscale(['up']))[0]
128
+
129
+ async def async_tailscale_down():
130
+ return (await async_tailscale(['down']))[0]
131
+
132
+ async def async_tailscale_is_online():
133
+ success, stdout = await async_tailscale(['status', '--json', '--peers=false', '--self=true'])
134
+ if success:
135
+ status = json.loads(stdout)
136
+ return success and status['BackendState'] == 'Running' and status['Self']['Online']
137
+
117
138
  ########
118
139
 
119
140
  class Secrets:
@@ -258,10 +279,9 @@ class StateSerializer:
258
279
  e.add_note("Missing '=' in state")
259
280
  raise
260
281
 
261
- class State:
282
+ class PhoneState:
262
283
  WIFI = 'wifi'
263
284
  PFTPD = 'pftpd'
264
- NAMES = { WIFI: "Wi-Fi", PFTPD: "pFTPd"}
265
285
 
266
286
  @abstractmethod
267
287
  async def get(self, repeat: float, timeout: float) -> dict:
@@ -374,7 +394,7 @@ class AutomateTailscaleManager(Manager):
374
394
  async def stop(self):
375
395
  await self.automate.send_message('stop-tailscale')
376
396
 
377
- class AutomateState(State):
397
+ class AutomatePhoneState(PhoneState):
378
398
  VARIABLE_STATE = 'state'
379
399
 
380
400
  def __init__(self, session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, external_url: str):
@@ -384,7 +404,7 @@ class AutomateState(State):
384
404
  self.external_url = external_url
385
405
 
386
406
  async def get(self, repeat: float, timeout: float):
387
- logger.info("Getting state...")
407
+ logger.info("Getting Phone state...")
388
408
  # first test funnel + webhooks availability, to not wait for a reply if local tailscale or funnel is down
389
409
  # though it will be routed locally, it will not go out to Tailscale's TCP forwarder servers, so the route is different from what Automate will see
390
410
  logger.debug("Testing funnel with pinging local webhook (timeout is %ds)", int(timeout))
@@ -395,23 +415,23 @@ class AutomateState(State):
395
415
  except:
396
416
  raise RuntimeError(f"Local Tailscale is down or local Funnel is not configured properly for {self.external_url}")
397
417
  # get state
398
- logger.debug("Getting state (repeat after %ds, timeout is %ds)", int(repeat), int(timeout))
399
- self.webhooks.subscribe_variable(AutomateState.VARIABLE_STATE)
418
+ logger.debug("Getting Phone state (repeat after %ds, timeout is %ds)", int(repeat), int(timeout))
419
+ self.webhooks.subscribe_variable(AutomatePhoneState.VARIABLE_STATE)
400
420
  try:
401
421
  async with asyncio.timeout(timeout):
402
422
  while True:
403
423
  try:
404
- await self.automate.send_message(f'get-state;{self.external_url}{Webhooks.get_variable_path(AutomateState.VARIABLE_STATE)}')
405
- state = await self.webhooks.get_variable(AutomateState.VARIABLE_STATE, min(repeat, timeout))
424
+ await self.automate.send_message(f'get-state;{self.external_url}{Webhooks.get_variable_path(AutomatePhoneState.VARIABLE_STATE)}')
425
+ state = await self.webhooks.get_variable(AutomatePhoneState.VARIABLE_STATE, min(repeat, timeout))
406
426
  break
407
427
  except TimeoutError:
408
428
  pass
409
429
  except TimeoutError as e:
410
- e.add_note(f"Can't get value of {AutomateState.VARIABLE_STATE} for {timeout} seconds - please check on your phone in the Automate app, that the prim-ctrl flow is running")
430
+ e.add_note(f"Can't get value of {AutomatePhoneState.VARIABLE_STATE} for {timeout} seconds - please check on your phone in the Automate app, that the prim-ctrl flow is running")
411
431
  raise
412
432
  finally:
413
- self.webhooks.unsubscribe_variable(AutomateState.VARIABLE_STATE)
414
- logger.info(" state is %s", state)
433
+ self.webhooks.unsubscribe_variable(AutomatePhoneState.VARIABLE_STATE)
434
+ logger.info("Phone state is %s", state)
415
435
  return StateSerializer.loads(state)
416
436
 
417
437
  ########
@@ -567,8 +587,8 @@ class ZeroconfService(Manageable):
567
587
  ########
568
588
 
569
589
  class Phone:
570
- def __init__(self, local_sftp: ZeroconfService, vpn: Device | None, remote_sftp: Service | None, state: State | None):
571
- self.local_sftp = local_sftp
590
+ def __init__(self, zeroconf_sftp: ZeroconfService, vpn: Device | None, remote_sftp: Service | None, state: PhoneState | None):
591
+ self.zeroconf_sftp = zeroconf_sftp
572
592
  self.vpn = vpn
573
593
  self.remote_sftp = remote_sftp
574
594
  self.state = state
@@ -588,28 +608,53 @@ class pFTPdServiceListener(ServiceListener):
588
608
  def del_service(self, service_name: str):
589
609
  pass
590
610
 
591
- class pFTPd(Service):
592
- pass
611
+ class RemotepFTPd(Service):
612
+ def __init__(self, host: str, port: int, manager: Manager):
613
+ super().__init__(host, port, manager)
614
+ self.__qualname__ = "pFTPd"
593
615
 
594
- class pFTPdZeroconf(ZeroconfService):
595
- def __init__(self, *args, **kwargs):
596
- super().__init__(*args, **kwargs)
597
- self.__qualname__ = pFTPd.__qualname__
616
+ class ZeroconfpFTPd(ZeroconfService):
617
+ def __init__(self, service_name: str, service_cache: ServiceCache, service_resolver: ServiceResolver, manager: Manager):
618
+ super().__init__(service_name, service_cache, service_resolver, manager)
619
+ self.__qualname__ = "pFTPd"
598
620
 
599
- class Tailscale(Device):
621
+ class RemoteTailscale(Device):
600
622
  def __init__(self, tailnet: str, machine_name: str, manager: Manager):
601
623
  super().__init__(f'{machine_name}.{tailnet}', manager)
602
624
  self.tailnet = tailnet
625
+ self.__qualname__ = "Remote Tailscale"
603
626
 
604
627
  class Funnel:
605
628
  LOCAL_HOST = '127.0.0.1'
606
629
 
607
- def __init__(self, tailscale: Tailscale, machine_name: str, local_port: int, local_path: str, external_port: int):
630
+ def __init__(self, tailscale: RemoteTailscale, machine_name: str, local_port: int, local_path: str, external_port: int):
608
631
  self.local_port = local_port
609
632
  self.external_url = f'https://{machine_name}.{tailscale.tailnet}:{external_port}{local_path}'
610
633
 
611
634
  ########
612
635
 
636
+ class Local:
637
+ def __init__(self, vpn: Manageable | None):
638
+ self.vpn = vpn
639
+
640
+ class LocalTailscaleManager(Manager):
641
+ async def start(self):
642
+ await async_tailscale_up()
643
+
644
+ async def stop(self):
645
+ await async_tailscale_down()
646
+
647
+ class LocalTailscale(Manageable):
648
+ def __init__(self):
649
+ super().__init__(LocalTailscaleManager())
650
+ self.__qualname__ = "Local Tailscale"
651
+
652
+ async def ping(self, availability_hint: bool | None = None):
653
+ logger.debug("Pinging %s", LazyStr(self.get_class_name))
654
+ return await async_tailscale_is_online()
655
+
656
+ ########
657
+
613
658
  class WideHelpFormatter(argparse.RawTextHelpFormatter):
614
659
  def __init__(self, prog: str, indent_increment: int = 2, max_help_position: int = 35, width: int | None = None) -> None:
615
660
  super().__init__(prog, indent_increment, max_help_position, width)
@@ -623,11 +668,12 @@ async def gather_with_taskgroup(*coros):
623
668
  raise eg.exceptions[0] from (None if len(eg.exceptions) == 1 else eg)
624
669
 
625
670
  class Control:
626
- WIFI = 'wifi'
627
- VPN = 'vpn'
628
- SFTP = 'sftp'
671
+ PHONE_WIFI = 'remote-wifi'
672
+ LOCAL_VPN = 'local-vpn'
673
+ PHONE_VPN = 'remote-vpn'
674
+ PHONE_SFTP = 'remote-sftp'
629
675
  CONNECTED = 'connected'
630
- LOCAL = 'local'
676
+ ZEROCONF = 'local'
631
677
  REMOTE = 'remote'
632
678
 
633
679
  @staticmethod
@@ -668,51 +714,60 @@ class Control:
668
714
  if args.restore_state and args.intent != 'stop':
669
715
  raise ValueError("The --restore-state option can be enabled only for the stop intent")
670
716
 
671
- async def execute(self, args, phone: Phone):
717
+ async def execute(self, args, local: Local, phone: Phone):
672
718
  async def _stop(restore_state: dict | None):
673
- if phone.vpn and phone.remote_sftp and await phone.vpn.test():
674
- if (restore_state is None or not restore_state.get(Control.SFTP, False)) and await phone.remote_sftp.test():
719
+ if local.vpn and phone.vpn and phone.remote_sftp and await local.vpn.test() and await phone.vpn.test():
720
+ if (restore_state is None or not restore_state.get(Control.PHONE_SFTP, False)) and await phone.remote_sftp.test():
675
721
  await phone.remote_sftp.stop(10, 30)
676
- if restore_state is None or not restore_state.get(Control.VPN, False):
722
+ if restore_state is None or not restore_state.get(Control.PHONE_VPN, False):
677
723
  await phone.vpn.stop(10, 60)
678
724
  else:
679
- if (restore_state is None or not restore_state.get(Control.SFTP, False)):
680
- await phone.local_sftp.stop(10, 30)
725
+ if (restore_state is None or not restore_state.get(Control.PHONE_SFTP, False)):
726
+ await phone.zeroconf_sftp.stop(10, 30)
727
+ if local.vpn:
728
+ if restore_state is None or not restore_state.get(Control.LOCAL_VPN, False):
729
+ await local.vpn.stop(10, 30)
681
730
  match args.intent:
682
731
  case 'test':
683
- if phone.vpn and phone.remote_sftp and phone.state:
684
- phone_state, _vpn_state = await gather_with_taskgroup(phone.state.get(10, 30), phone.vpn.test())
685
- for k, v in phone_state.items():
686
- logger.info("%s is %s", LazyStr(lambda: State.NAMES[k]), LazyStr(StateSerializer.dump_value, v))
687
- elif phone.vpn and phone.remote_sftp and await phone.vpn.test():
732
+ if local.vpn and phone.vpn and phone.remote_sftp and phone.state:
733
+ if await local.vpn.test():
734
+ phone_state, _vpn_state = await gather_with_taskgroup(phone.state.get(10, 30), phone.vpn.test())
735
+ elif local.vpn and phone.vpn and phone.remote_sftp and await local.vpn.test() and await phone.vpn.test():
688
736
  await phone.remote_sftp.test()
689
737
  else:
690
- await phone.local_sftp.test()
738
+ await phone.zeroconf_sftp.test()
691
739
  case 'start':
692
- if phone.vpn and phone.remote_sftp:
740
+ if local.vpn and phone.vpn and phone.remote_sftp:
693
741
  state = dict()
694
- local_accessible = False
742
+ zeroconf_accessible = False
695
743
  remote_accessible = False
696
- # gather state info
744
+
745
+ # gather local state info
746
+ local_vpn_state = await local.vpn.test()
747
+ state[Control.LOCAL_VPN] = local_vpn_state
748
+ # start changing local state - we need a local vpn to be able to access the state of the remote vpn and optionally the phone
749
+ await local.vpn.start(10, 30)
750
+
751
+ # gather remote state info
697
752
  if phone.state:
698
- phone_state, vpn_state = await gather_with_taskgroup(phone.state.get(10, 30), phone.vpn.test())
699
- state[Control.WIFI] = phone_state[State.WIFI]
700
- state[Control.VPN] = vpn_state
701
- state[Control.SFTP] = phone_state[State.PFTPD]
702
- if not state[Control.WIFI] and not args.accept_cellular:
753
+ phone_state, phone_vpn_state = await gather_with_taskgroup(phone.state.get(10, 30), phone.vpn.test())
754
+ state[Control.PHONE_WIFI] = phone_state[PhoneState.WIFI]
755
+ state[Control.PHONE_VPN] = phone_vpn_state
756
+ state[Control.PHONE_SFTP] = phone_state[PhoneState.PFTPD]
757
+ if not state[Control.PHONE_WIFI] and not args.accept_cellular:
703
758
  raise RuntimeError(f"Phone is not on Wi-Fi network")
704
759
  else:
705
- state[Control.VPN] = await phone.vpn.test()
706
- if state[Control.VPN]:
707
- state[Control.SFTP] = remote_accessible = await phone.remote_sftp.test()
708
- # start changing state
760
+ state[Control.PHONE_VPN] = await phone.vpn.test()
761
+ if state[Control.PHONE_VPN]:
762
+ state[Control.PHONE_SFTP] = remote_accessible = await phone.remote_sftp.test()
763
+ # start changing remote state
709
764
  try:
710
765
  if phone.state:
711
- if not state[Control.SFTP]:
712
- if not state[Control.VPN]:
713
- if state[Control.WIFI]:
766
+ if not state[Control.PHONE_SFTP]:
767
+ if not state[Control.PHONE_VPN]:
768
+ if state[Control.PHONE_WIFI]:
714
769
  try:
715
- local_accessible = await phone.local_sftp.start(10, 30)
770
+ zeroconf_accessible = await phone.zeroconf_sftp.start(10, 30)
716
771
  except TimeoutError:
717
772
  await phone.vpn.start(10, 60)
718
773
  remote_accessible = await phone.remote_sftp.test()
@@ -721,40 +776,40 @@ class Control:
721
776
  remote_accessible = await phone.remote_sftp.start(10, 30)
722
777
  else:
723
778
  remote_accessible = await phone.remote_sftp.start(10, 30)
724
- if state[Control.WIFI]:
725
- local_accessible = await phone.local_sftp.test()
779
+ if state[Control.PHONE_WIFI]:
780
+ zeroconf_accessible = await phone.zeroconf_sftp.test()
726
781
  else:
727
- if not state[Control.VPN]:
728
- if not state[Control.WIFI] or not (local_accessible := await phone.local_sftp.test()):
782
+ if not state[Control.PHONE_VPN]:
783
+ if not state[Control.PHONE_WIFI] or not (zeroconf_accessible := await phone.zeroconf_sftp.test()):
729
784
  await phone.vpn.start(10, 60)
730
785
  remote_accessible = await phone.remote_sftp.test()
731
786
  else:
732
- local_accessible, remote_accessible = await gather_with_taskgroup(phone.local_sftp.test(), phone.remote_sftp.test())
787
+ zeroconf_accessible, remote_accessible = await gather_with_taskgroup(phone.zeroconf_sftp.test(), phone.remote_sftp.test())
733
788
  else:
734
- if not state[Control.VPN]:
735
- if not (local_accessible := await phone.local_sftp.test()):
789
+ if not state[Control.PHONE_VPN]:
790
+ if not (zeroconf_accessible := await phone.zeroconf_sftp.test()):
736
791
  try:
737
- local_accessible = await phone.local_sftp.start(10, 30)
792
+ zeroconf_accessible = await phone.zeroconf_sftp.start(10, 30)
738
793
  except TimeoutError:
739
794
  await phone.vpn.start(10, 60)
740
795
  remote_accessible = await phone.remote_sftp.test()
741
796
  else:
742
- if not state[Control.SFTP]:
797
+ if not state[Control.PHONE_SFTP]:
743
798
  remote_accessible = await phone.remote_sftp.start(10, 30)
744
- local_accessible = await phone.local_sftp.test()
745
- if not local_accessible and not remote_accessible:
799
+ zeroconf_accessible = await phone.zeroconf_sftp.test()
800
+ if not zeroconf_accessible and not remote_accessible:
746
801
  raise RuntimeError(f"Even when {phone.vpn.get_class_name()} and {phone.remote_sftp.get_class_name()} is started, {phone.remote_sftp.get_class_name()} is still not accessible")
747
802
  except:
748
803
  await _stop(state)
749
804
  raise
750
805
  if not args.backup_state:
751
806
  state = dict()
752
- state[Control.CONNECTED] = Control.LOCAL if local_accessible else Control.REMOTE
807
+ state[Control.CONNECTED] = Control.ZEROCONF if zeroconf_accessible else Control.REMOTE
753
808
  print(StateSerializer.dumps(state))
754
809
  else:
755
- if not await phone.local_sftp.test():
810
+ if not await phone.zeroconf_sftp.test():
756
811
  try:
757
- await phone.local_sftp.start(10, 30)
812
+ await phone.zeroconf_sftp.start(10, 30)
758
813
  except:
759
814
  await _stop(None)
760
815
  raise
@@ -767,7 +822,7 @@ class AutomateControl(Control):
767
822
  parser = subparsers.add_parser('Automate', aliases=['a'],
768
823
  description="Remote control of your phone's Primitive FTPd and optionally Tailscale app statuses via the Automate app, for more details see https://github.com/lmagyar/prim-ctrl\n\n"
769
824
  "Note: you must install Automate app on your phone, download prim-ctrl flow into it, and configure your Google account in the flow to receive messages (see the project's GitHub page for more details)\n"
770
- "Note: optionally if your phone is not accessible on local network but your laptop is part of the Tailscale VPN then Tailscale VPN can be started on the phone\n"
825
+ "Note: optionally if your phone is not accessible on local network but your laptop and phone is part of the Tailscale VPN then Tailscale VPN can be started on the phone\n"
771
826
  "Note: optionally if your laptop is accessible through Tailscale Funnel then VPN on cellular can be refused and app statuses on the phone can be backed up and restored\n\n"
772
827
  "Output: even when -b option is not used, the script will output 'connected=(local|remote)', what you can use to determine whether to use -a option for the prim-sync script",
773
828
  formatter_class=WideHelpFormatter)
@@ -830,15 +885,17 @@ class AutomateControl(Control):
830
885
  await service_browser.add_service_listener(service_listener)
831
886
 
832
887
  automate = Automate(Secrets(), session, args.automate_account, args.automate_device, args.automate_tokenfile)
833
- local_pftpd = pFTPdZeroconf(args.server_name, service_cache, service_resolver, AutomatepFTPdManager(automate))
834
- tailscale = Tailscale(args.tailscale[0], args.tailscale[1], AutomateTailscaleManager(automate)) if args.tailscale else None
835
- remote_pftpd = pFTPd(tailscale.host, int(args.tailscale[2]), local_pftpd.manager) if tailscale else None
836
- funnel = Funnel(tailscale, args.funnel[0], int(args.funnel[1]), args.funnel[2], int(args.funnel[3])) if tailscale and args.funnel else None
888
+ local_tailscale = LocalTailscale() if args.tailscale else None
889
+ remote_tailscale = RemoteTailscale(args.tailscale[0], args.tailscale[1], AutomateTailscaleManager(automate)) if args.tailscale else None
890
+ zeroconf_pftpd = ZeroconfpFTPd(args.server_name, service_cache, service_resolver, AutomatepFTPdManager(automate))
891
+ remote_pftpd = RemotepFTPd(remote_tailscale.host, int(args.tailscale[2]), zeroconf_pftpd.manager) if remote_tailscale else None
892
+ funnel = Funnel(remote_tailscale, args.funnel[0], int(args.funnel[1]), args.funnel[2], int(args.funnel[3])) if remote_tailscale and args.funnel else None
837
893
 
838
894
  async with Webhooks(Funnel.LOCAL_HOST, funnel.local_port) if funnel else nullcontext() as webhooks:
839
- state = AutomateState(session, webhooks, automate, funnel.external_url) if funnel and webhooks else None
840
- phone = Phone(local_pftpd, tailscale, remote_pftpd, state)
841
- await self.execute(args, phone)
895
+ local = Local(local_tailscale)
896
+ automate_phone_state = AutomatePhoneState(session, webhooks, automate, funnel.external_url) if funnel and webhooks else None
897
+ phone = Phone(zeroconf_pftpd, remote_tailscale, remote_pftpd, automate_phone_state)
898
+ await self.execute(args, local, phone)
842
899
 
843
900
  async def main():
844
901
  args = None
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "prim-ctrl"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "Primitive Ctrl - Remote control of your phone's Primitive FTPd Android SFTP server and optionally Tailscale VPN."
5
5
  license = "Apache-2.0"
6
6
  authors = ["Laszlo Magyar <lmagyar1973@gmail.com>"]
File without changes